init
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/user/aevs/internal/config"
|
||||
"github.com/user/aevs/internal/storage"
|
||||
)
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Configure storage credentials",
|
||||
Long: `Interactive configuration of S3-compatible storage credentials.`,
|
||||
RunE: runConfig,
|
||||
}
|
||||
|
||||
func runConfig(cmd *cobra.Command, args []string) error {
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("AEVS Configuration")
|
||||
fmt.Println("==================")
|
||||
fmt.Println()
|
||||
|
||||
// Storage type
|
||||
storageType := promptWithDefault(reader, "Storage type", config.DefaultStorageType)
|
||||
|
||||
// Endpoint
|
||||
endpoint := promptWithDefault(reader, "S3 Endpoint", config.DefaultEndpoint)
|
||||
|
||||
// Region
|
||||
region := promptWithDefault(reader, "AWS Region", config.DefaultRegion)
|
||||
|
||||
// Bucket name
|
||||
bucket := prompt(reader, "Bucket name")
|
||||
if bucket == "" {
|
||||
return fmt.Errorf("bucket name is required")
|
||||
}
|
||||
|
||||
// Access key
|
||||
accessKey := prompt(reader, "Access Key ID")
|
||||
if accessKey == "" {
|
||||
return fmt.Errorf("access key is required")
|
||||
}
|
||||
|
||||
// Secret key (hidden)
|
||||
fmt.Print("Secret Access Key: ")
|
||||
secretKeyBytes, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read secret key: %w", err)
|
||||
}
|
||||
secretKey := string(secretKeyBytes)
|
||||
if secretKey == "" {
|
||||
return fmt.Errorf("secret key is required")
|
||||
}
|
||||
|
||||
// Create config
|
||||
cfg := &config.GlobalConfig{
|
||||
Storage: config.StorageConfig{
|
||||
Type: storageType,
|
||||
Endpoint: endpoint,
|
||||
Region: region,
|
||||
Bucket: bucket,
|
||||
AccessKey: accessKey,
|
||||
SecretKey: secretKey,
|
||||
},
|
||||
}
|
||||
|
||||
// Test connection
|
||||
fmt.Println()
|
||||
fmt.Println("Testing connection...")
|
||||
|
||||
s3Storage, err := storage.NewS3Storage(&cfg.Storage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage client: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
if err := s3Storage.TestConnection(ctx); err != nil {
|
||||
return fmt.Errorf("connection test failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Successfully connected to s3://%s\n", bucket)
|
||||
fmt.Println()
|
||||
|
||||
// Save config
|
||||
if err := config.SaveGlobalConfig(cfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Config saved to %s\n", config.GlobalConfigPath())
|
||||
return nil
|
||||
}
|
||||
|
||||
// prompt reads a line from stdin
|
||||
func prompt(reader *bufio.Reader, question string) string {
|
||||
fmt.Printf("%s: ", question)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
return strings.TrimSpace(answer)
|
||||
}
|
||||
|
||||
// promptWithDefault reads a line from stdin with a default value
|
||||
func promptWithDefault(reader *bufio.Reader, question, defaultValue string) string {
|
||||
fmt.Printf("%s (%s): ", question, defaultValue)
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(answer)
|
||||
if answer == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return answer
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/user/aevs/internal/config"
|
||||
"github.com/user/aevs/internal/scanner"
|
||||
)
|
||||
|
||||
var (
|
||||
initForce bool
|
||||
)
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init [project-name]",
|
||||
Short: "Initialize project configuration",
|
||||
Long: `Initialize aevs.yaml configuration in the current directory.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runInit,
|
||||
}
|
||||
|
||||
func init() {
|
||||
initCmd.Flags().BoolVarP(&initForce, "force", "f", false, "overwrite existing aevs.yaml")
|
||||
}
|
||||
|
||||
func runInit(cmd *cobra.Command, args []string) error {
|
||||
configPath := config.DefaultProjectConfigFile
|
||||
|
||||
// Check if config already exists
|
||||
configExists := false
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
configExists = true
|
||||
}
|
||||
|
||||
// Scan for env files
|
||||
fmt.Println()
|
||||
fmt.Println("Scanning for env files...")
|
||||
fmt.Println()
|
||||
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
|
||||
envFiles, err := scanner.Scan(currentDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan directory: %w", err)
|
||||
}
|
||||
|
||||
if len(envFiles) == 0 {
|
||||
fmt.Println("Warning: No env files found.")
|
||||
fmt.Println()
|
||||
fmt.Println("You can add files manually to aevs.yaml later.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle existing config
|
||||
if configExists && !initForce {
|
||||
fmt.Printf("Project already initialized in %s\n", configPath)
|
||||
fmt.Println("Scanning for new env files...")
|
||||
fmt.Println()
|
||||
|
||||
// Load existing config
|
||||
existingCfg, err := config.LoadProjectConfig(configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load existing config: %w", err)
|
||||
}
|
||||
|
||||
// Find new files
|
||||
newFiles := findNewFiles(envFiles, existingCfg.Files)
|
||||
|
||||
if len(newFiles) == 0 {
|
||||
fmt.Println("No new files found.")
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf("Found %d new file(s):\n", len(newFiles))
|
||||
for _, file := range newFiles {
|
||||
fmt.Printf(" + %s\n", file)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Ask to add
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
fmt.Print("Add to aevs.yaml? [Y/n]: ")
|
||||
answer, _ := reader.ReadString('\n')
|
||||
answer = strings.TrimSpace(strings.ToLower(answer))
|
||||
|
||||
if answer == "n" || answer == "no" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add new files
|
||||
existingCfg.Files = append(existingCfg.Files, newFiles...)
|
||||
|
||||
// Save updated config
|
||||
if err := config.SaveProjectConfig(configPath, existingCfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Updated aevs.yaml")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create new config
|
||||
fmt.Printf("Found %d env file(s):\n", len(envFiles))
|
||||
for _, file := range envFiles {
|
||||
fmt.Printf(" ✓ %s\n", file)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Determine project name
|
||||
projectName := ""
|
||||
if len(args) > 0 {
|
||||
projectName = args[0]
|
||||
} else {
|
||||
// Use current directory name
|
||||
projectName = filepath.Base(currentDir)
|
||||
projectName = strings.ToLower(projectName)
|
||||
// Clean up project name (replace invalid chars with -)
|
||||
projectName = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||
return r
|
||||
}
|
||||
return '-'
|
||||
}, projectName)
|
||||
}
|
||||
|
||||
// Validate project name
|
||||
if err := config.ValidateProjectName(projectName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create config
|
||||
cfg := &config.ProjectConfig{
|
||||
Project: projectName,
|
||||
Files: envFiles,
|
||||
}
|
||||
|
||||
// Save config
|
||||
if err := config.SaveProjectConfig(configPath, cfg); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created aevs.yaml for project %q\n", projectName)
|
||||
fmt.Println()
|
||||
fmt.Println("Next steps:")
|
||||
fmt.Println(" 1. Review aevs.yaml")
|
||||
fmt.Println(" 2. Run 'aevs push' to upload files")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findNewFiles returns files that are in scanned but not in existing
|
||||
func findNewFiles(scanned, existing []string) []string {
|
||||
existingMap := make(map[string]bool)
|
||||
for _, file := range existing {
|
||||
existingMap[file] = true
|
||||
}
|
||||
|
||||
var newFiles []string
|
||||
for _, file := range scanned {
|
||||
if !existingMap[file] {
|
||||
newFiles = append(newFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
return newFiles
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/user/aevs/internal/config"
|
||||
"github.com/user/aevs/internal/storage"
|
||||
"github.com/user/aevs/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
listJSON bool
|
||||
)
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List projects in storage",
|
||||
Long: `Show all projects stored in cloud storage.`,
|
||||
RunE: runList,
|
||||
}
|
||||
|
||||
func init() {
|
||||
listCmd.Flags().BoolVar(&listJSON, "json", false, "output in JSON format")
|
||||
}
|
||||
|
||||
func runList(cmd *cobra.Command, args []string) error {
|
||||
// Load global config
|
||||
globalCfg, err := config.LoadGlobalConfig()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("no storage configured; run 'aevs config' first")
|
||||
}
|
||||
return fmt.Errorf("failed to load global config: %w", err)
|
||||
}
|
||||
|
||||
// Create storage client
|
||||
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage client: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// List projects
|
||||
projects, err := s3Storage.ListProjects(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list projects: %w", err)
|
||||
}
|
||||
|
||||
if len(projects) == 0 {
|
||||
fmt.Println("No projects found in storage.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load metadata for each project
|
||||
var projectInfos []types.ProjectInfo
|
||||
for _, project := range projects {
|
||||
metadataKey := fmt.Sprintf("%s/%s", project, config.MetadataFileName)
|
||||
metadataReader, err := s3Storage.Download(ctx, metadataKey)
|
||||
if err != nil {
|
||||
// Skip projects without metadata
|
||||
continue
|
||||
}
|
||||
|
||||
metadataBytes, err := io.ReadAll(metadataReader)
|
||||
metadataReader.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var metadata types.Metadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
projectInfos = append(projectInfos, types.ProjectInfo{
|
||||
Name: project,
|
||||
FileCount: len(metadata.Files),
|
||||
UpdatedAt: metadata.UpdatedAt,
|
||||
SizeBytes: metadata.SizeBytes,
|
||||
})
|
||||
}
|
||||
|
||||
if listJSON {
|
||||
// JSON output
|
||||
output, err := json.MarshalIndent(projectInfos, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
fmt.Println(string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Table output
|
||||
fmt.Println()
|
||||
fmt.Printf("Projects in s3://%s:\n", globalCfg.Storage.Bucket)
|
||||
fmt.Println()
|
||||
fmt.Printf(" %-25s %-8s %-20s %s\n", "PROJECT", "FILES", "UPDATED", "SIZE")
|
||||
|
||||
for _, info := range projectInfos {
|
||||
updatedStr := formatTime(info.UpdatedAt)
|
||||
sizeStr := formatSize(info.SizeBytes)
|
||||
fmt.Printf(" %-25s %-8d %-20s %s\n", info.Name, info.FileCount, updatedStr, sizeStr)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Total: %d project(s)\n", len(projectInfos))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatTime formats time in a human-readable way
|
||||
func formatTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
diff := now.Sub(t)
|
||||
|
||||
if diff < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if diff < time.Hour {
|
||||
mins := int(diff.Minutes())
|
||||
if mins == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", mins)
|
||||
}
|
||||
if diff < 24*time.Hour {
|
||||
hours := int(diff.Hours())
|
||||
if hours == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", hours)
|
||||
}
|
||||
if diff < 7*24*time.Hour {
|
||||
days := int(diff.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1 day ago"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
if diff < 30*24*time.Hour {
|
||||
weeks := int(diff.Hours() / 24 / 7)
|
||||
if weeks == 1 {
|
||||
return "1 week ago"
|
||||
}
|
||||
return fmt.Sprintf("%d weeks ago", weeks)
|
||||
}
|
||||
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// formatSize formats bytes in a human-readable way
|
||||
func formatSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/user/aevs/internal/archiver"
|
||||
"github.com/user/aevs/internal/config"
|
||||
"github.com/user/aevs/internal/storage"
|
||||
"github.com/user/aevs/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
pullConfig string
|
||||
pullForce bool
|
||||
pullDryRun bool
|
||||
)
|
||||
|
||||
var pullCmd = &cobra.Command{
|
||||
Use: "pull [project-name]",
|
||||
Short: "Pull env files from storage",
|
||||
Long: `Download .env files from cloud storage.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: runPull,
|
||||
}
|
||||
|
||||
func init() {
|
||||
pullCmd.Flags().StringVarP(&pullConfig, "config", "c", config.DefaultProjectConfigFile, "path to project config")
|
||||
pullCmd.Flags().BoolVarP(&pullForce, "force", "f", false, "overwrite files without confirmation")
|
||||
pullCmd.Flags().BoolVar(&pullDryRun, "dry-run", false, "show what would be downloaded without downloading")
|
||||
}
|
||||
|
||||
func runPull(cmd *cobra.Command, args []string) error {
|
||||
// Load global config
|
||||
globalCfg, err := config.LoadGlobalConfig()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("no storage configured; run 'aevs config' first")
|
||||
}
|
||||
return fmt.Errorf("failed to load global config: %w", err)
|
||||
}
|
||||
|
||||
// Determine project name
|
||||
projectName := ""
|
||||
if len(args) > 0 {
|
||||
// Project name from argument
|
||||
projectName = args[0]
|
||||
} else {
|
||||
// Try to load from local config
|
||||
projectCfg, err := config.LoadProjectConfig(pullConfig)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("project name required; usage: aevs pull <project-name>")
|
||||
}
|
||||
return fmt.Errorf("failed to load project config: %w", err)
|
||||
}
|
||||
projectName = projectCfg.Project
|
||||
}
|
||||
|
||||
// Create storage client
|
||||
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage client: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Download metadata
|
||||
metadataKey := fmt.Sprintf("%s/%s", projectName, config.MetadataFileName)
|
||||
metadataReader, err := s3Storage.Download(ctx, metadataKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("project %q not found in storage", projectName)
|
||||
}
|
||||
defer metadataReader.Close()
|
||||
|
||||
metadataBytes, err := io.ReadAll(metadataReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata types.Metadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
return fmt.Errorf("failed to parse metadata: %w", err)
|
||||
}
|
||||
|
||||
// Download archive
|
||||
archiveKey := fmt.Sprintf("%s/%s", projectName, config.ArchiveFileName)
|
||||
archiveReader, err := s3Storage.Download(ctx, archiveKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download archive: %w", err)
|
||||
}
|
||||
defer archiveReader.Close()
|
||||
|
||||
archiveBytes, err := io.ReadAll(archiveReader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read archive: %w", err)
|
||||
}
|
||||
|
||||
if pullDryRun {
|
||||
fmt.Println()
|
||||
fmt.Println("Dry run - no changes will be made")
|
||||
fmt.Println()
|
||||
fmt.Println("Would download:")
|
||||
for _, file := range metadata.Files {
|
||||
fmt.Printf(" %s\n", file)
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Pulling %q from storage...\n", projectName)
|
||||
fmt.Println()
|
||||
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
|
||||
// Extract to temp directory first to handle conflicts
|
||||
tempDir, err := os.MkdirTemp("", "aevs-pull-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
extractedFiles, err := archiver.Extract(bytes.NewReader(archiveBytes), tempDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to extract archive: %w", err)
|
||||
}
|
||||
|
||||
// Process each file
|
||||
overwriteAll := false
|
||||
skipAll := false
|
||||
created := 0
|
||||
updated := 0
|
||||
unchanged := 0
|
||||
skipped := 0
|
||||
|
||||
for _, file := range extractedFiles {
|
||||
tempPath := filepath.Join(tempDir, file)
|
||||
targetPath := filepath.Join(currentDir, file)
|
||||
|
||||
// Check if file exists
|
||||
_, err := os.Stat(targetPath)
|
||||
fileExists := err == nil
|
||||
|
||||
if !fileExists {
|
||||
// File doesn't exist - create it
|
||||
dir := filepath.Dir(targetPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
if err := copyFile(tempPath, targetPath); err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", file, err)
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ %s (created)\n", file)
|
||||
created++
|
||||
continue
|
||||
}
|
||||
|
||||
// File exists - check if different
|
||||
same, err := filesAreSame(tempPath, targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compare files: %w", err)
|
||||
}
|
||||
|
||||
if same {
|
||||
fmt.Printf(" - %s (unchanged)\n", file)
|
||||
unchanged++
|
||||
continue
|
||||
}
|
||||
|
||||
// Files differ - handle conflict
|
||||
if pullForce || overwriteAll {
|
||||
if err := copyFile(tempPath, targetPath); err != nil {
|
||||
return fmt.Errorf("failed to overwrite file %s: %w", file, err)
|
||||
}
|
||||
fmt.Printf(" ✓ %s (overwritten)\n", file)
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
|
||||
if skipAll {
|
||||
fmt.Printf(" - %s (skipped)\n", file)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Ask user what to do
|
||||
fmt.Println()
|
||||
fmt.Printf("File %s already exists and differs from remote.\n", file)
|
||||
fmt.Print("[o]verwrite / [s]kip / [d]iff / [O]verwrite all / [S]kip all: ")
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
choice, _ := reader.ReadString('\n')
|
||||
choice = strings.TrimSpace(strings.ToLower(choice))
|
||||
|
||||
switch choice {
|
||||
case "o":
|
||||
if err := copyFile(tempPath, targetPath); err != nil {
|
||||
return fmt.Errorf("failed to overwrite file %s: %w", file, err)
|
||||
}
|
||||
fmt.Printf(" ✓ %s (overwritten)\n", file)
|
||||
updated++
|
||||
case "s":
|
||||
fmt.Printf(" - %s (skipped)\n", file)
|
||||
skipped++
|
||||
case "d":
|
||||
// Show diff (simple version - just show both)
|
||||
fmt.Println("\nLocal version:")
|
||||
localContent, _ := os.ReadFile(targetPath)
|
||||
fmt.Println(string(localContent))
|
||||
fmt.Println("\nRemote version:")
|
||||
remoteContent, _ := os.ReadFile(tempPath)
|
||||
fmt.Println(string(remoteContent))
|
||||
fmt.Println()
|
||||
// Ask again after showing diff
|
||||
fmt.Print("[o]verwrite / [s]kip: ")
|
||||
choice2, _ := reader.ReadString('\n')
|
||||
choice2 = strings.TrimSpace(strings.ToLower(choice2))
|
||||
if choice2 == "o" {
|
||||
if err := copyFile(tempPath, targetPath); err != nil {
|
||||
return fmt.Errorf("failed to overwrite file %s: %w", file, err)
|
||||
}
|
||||
fmt.Printf(" ✓ %s (overwritten)\n", file)
|
||||
updated++
|
||||
} else {
|
||||
fmt.Printf(" - %s (skipped)\n", file)
|
||||
skipped++
|
||||
}
|
||||
case "shift+o", "O":
|
||||
overwriteAll = true
|
||||
if err := copyFile(tempPath, targetPath); err != nil {
|
||||
return fmt.Errorf("failed to overwrite file %s: %w", file, err)
|
||||
}
|
||||
fmt.Printf(" ✓ %s (overwritten)\n", file)
|
||||
updated++
|
||||
case "shift+s", "S":
|
||||
skipAll = true
|
||||
fmt.Printf(" - %s (skipped)\n", file)
|
||||
skipped++
|
||||
default:
|
||||
fmt.Printf(" - %s (skipped)\n", file)
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
var summary []string
|
||||
if created > 0 {
|
||||
summary = append(summary, fmt.Sprintf("%d created", created))
|
||||
}
|
||||
if updated > 0 {
|
||||
summary = append(summary, fmt.Sprintf("%d updated", updated))
|
||||
}
|
||||
if unchanged > 0 {
|
||||
summary = append(summary, fmt.Sprintf("%d unchanged", unchanged))
|
||||
}
|
||||
if skipped > 0 {
|
||||
summary = append(summary, fmt.Sprintf("%d skipped", skipped))
|
||||
}
|
||||
|
||||
if len(summary) > 0 {
|
||||
fmt.Printf("Done. %s.\n", strings.Join(summary, ", "))
|
||||
} else {
|
||||
fmt.Println("Done.")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies a file from src to dst
|
||||
func copyFile(src, dst string) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, data, 0644)
|
||||
}
|
||||
|
||||
// filesAreSame checks if two files have the same content
|
||||
func filesAreSame(path1, path2 string) (bool, error) {
|
||||
data1, err := os.ReadFile(path1)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
data2, err := os.ReadFile(path2)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return bytes.Equal(data1, data2), nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/user/aevs/internal/archiver"
|
||||
"github.com/user/aevs/internal/config"
|
||||
"github.com/user/aevs/internal/storage"
|
||||
"github.com/user/aevs/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
pushConfig string
|
||||
pushDryRun bool
|
||||
)
|
||||
|
||||
var pushCmd = &cobra.Command{
|
||||
Use: "push",
|
||||
Short: "Push env files to storage",
|
||||
Long: `Upload local .env files to cloud storage.`,
|
||||
RunE: runPush,
|
||||
}
|
||||
|
||||
func init() {
|
||||
pushCmd.Flags().StringVarP(&pushConfig, "config", "c", config.DefaultProjectConfigFile, "path to project config")
|
||||
pushCmd.Flags().BoolVar(&pushDryRun, "dry-run", false, "show what would be uploaded without uploading")
|
||||
}
|
||||
|
||||
func runPush(cmd *cobra.Command, args []string) error {
|
||||
// Load global config
|
||||
globalCfg, err := config.LoadGlobalConfig()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("no storage configured; run 'aevs config' first")
|
||||
}
|
||||
return fmt.Errorf("failed to load global config: %w", err)
|
||||
}
|
||||
|
||||
// Load project config
|
||||
projectCfg, err := config.LoadProjectConfig(pushConfig)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("no aevs.yaml found; run 'aevs init' first")
|
||||
}
|
||||
return fmt.Errorf("failed to load project config: %w", err)
|
||||
}
|
||||
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
|
||||
// Check that all files exist
|
||||
var fileSizes []int64
|
||||
for _, file := range projectCfg.Files {
|
||||
fullPath := filepath.Join(currentDir, file)
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("file not found: %s", file)
|
||||
}
|
||||
return fmt.Errorf("failed to stat file %s: %w", file, err)
|
||||
}
|
||||
fileSizes = append(fileSizes, info.Size())
|
||||
}
|
||||
|
||||
// Create archive
|
||||
archive, archiveSize, err := archiver.Create(projectCfg.Files, currentDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create archive: %w", err)
|
||||
}
|
||||
|
||||
if pushDryRun {
|
||||
fmt.Println()
|
||||
fmt.Println("Dry run - no changes will be made")
|
||||
fmt.Println()
|
||||
fmt.Println("Would upload:")
|
||||
for i, file := range projectCfg.Files {
|
||||
fmt.Printf(" %s (%d bytes)\n", file, fileSizes[i])
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Printf("Target: s3://%s/%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project, config.ArchiveFileName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create storage client
|
||||
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage client: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Pushing %q to storage...\n", projectCfg.Project)
|
||||
fmt.Println()
|
||||
|
||||
// Upload archive
|
||||
archiveKey := fmt.Sprintf("%s/%s", projectCfg.Project, config.ArchiveFileName)
|
||||
if err := s3Storage.Upload(ctx, archiveKey, archive, archiveSize); err != nil {
|
||||
return fmt.Errorf("failed to upload archive: %w", err)
|
||||
}
|
||||
|
||||
// Print file list
|
||||
for i, file := range projectCfg.Files {
|
||||
fmt.Printf(" ✓ %s (%d bytes)\n", file, fileSizes[i])
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Get hostname
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "unknown"
|
||||
}
|
||||
|
||||
// Create and upload metadata
|
||||
metadata := types.Metadata{
|
||||
UpdatedAt: time.Now(),
|
||||
Files: projectCfg.Files,
|
||||
Machine: hostname,
|
||||
SizeBytes: archiveSize,
|
||||
}
|
||||
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataKey := fmt.Sprintf("%s/%s", projectCfg.Project, config.MetadataFileName)
|
||||
if err := s3Storage.Upload(ctx, metadataKey, bytes.NewReader(metadataJSON), int64(len(metadataJSON))); err != nil {
|
||||
return fmt.Errorf("failed to upload metadata: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Uploaded to s3://%s/%s\n", globalCfg.Storage.Bucket, archiveKey)
|
||||
fmt.Printf("Total: %d files, %d bytes\n", len(projectCfg.Files), archiveSize)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
// Global flags
|
||||
verbose bool
|
||||
debug bool
|
||||
)
|
||||
|
||||
// rootCmd represents the base command
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "aevs",
|
||||
Short: "Sync .env files between machines",
|
||||
Long: `AEVS is a CLI tool for syncing .env files between machines using S3-compatible storage.
|
||||
|
||||
It helps you keep environment variables synchronized across development environments
|
||||
without committing them to version control.`,
|
||||
}
|
||||
|
||||
// Execute executes the root command
|
||||
func Execute() error {
|
||||
return rootCmd.Execute()
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Global flags
|
||||
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
|
||||
rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "debug output")
|
||||
|
||||
// Subcommands
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(initCmd)
|
||||
rootCmd.AddCommand(pushCmd)
|
||||
rootCmd.AddCommand(pullCmd)
|
||||
rootCmd.AddCommand(listCmd)
|
||||
rootCmd.AddCommand(statusCmd)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/user/aevs/internal/config"
|
||||
"github.com/user/aevs/internal/storage"
|
||||
"github.com/user/aevs/internal/types"
|
||||
)
|
||||
|
||||
var (
|
||||
statusConfig string
|
||||
)
|
||||
|
||||
var statusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show sync status",
|
||||
Long: `Show the synchronization status of the current project.`,
|
||||
RunE: runStatus,
|
||||
}
|
||||
|
||||
func init() {
|
||||
statusCmd.Flags().StringVarP(&statusConfig, "config", "c", config.DefaultProjectConfigFile, "path to project config")
|
||||
}
|
||||
|
||||
func runStatus(cmd *cobra.Command, args []string) error {
|
||||
// Load global config
|
||||
globalCfg, err := config.LoadGlobalConfig()
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("no storage configured; run 'aevs config' first")
|
||||
}
|
||||
return fmt.Errorf("failed to load global config: %w", err)
|
||||
}
|
||||
|
||||
// Load project config
|
||||
projectCfg, err := config.LoadProjectConfig(statusConfig)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("no aevs.yaml found; run 'aevs init' first")
|
||||
}
|
||||
return fmt.Errorf("failed to load project config: %w", err)
|
||||
}
|
||||
|
||||
// Create storage client
|
||||
s3Storage, err := storage.NewS3Storage(&globalCfg.Storage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create storage client: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Try to download metadata
|
||||
metadataKey := fmt.Sprintf("%s/%s", projectCfg.Project, config.MetadataFileName)
|
||||
metadataReader, err := s3Storage.Download(ctx, metadataKey)
|
||||
|
||||
var metadata *types.Metadata
|
||||
if err != nil {
|
||||
// Project not in storage yet
|
||||
fmt.Println()
|
||||
fmt.Printf("Project: %s\n", projectCfg.Project)
|
||||
fmt.Printf("Storage: s3://%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project)
|
||||
fmt.Println()
|
||||
fmt.Println("Project not found in storage. Run 'aevs push' first.")
|
||||
return nil
|
||||
}
|
||||
|
||||
metadataBytes, err := io.ReadAll(metadataReader)
|
||||
metadataReader.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
|
||||
metadata = &types.Metadata{}
|
||||
if err := json.Unmarshal(metadataBytes, metadata); err != nil {
|
||||
return fmt.Errorf("failed to parse metadata: %w", err)
|
||||
}
|
||||
|
||||
currentDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
|
||||
// Build map of remote files
|
||||
remoteFiles := make(map[string]bool)
|
||||
for _, file := range metadata.Files {
|
||||
remoteFiles[file] = true
|
||||
}
|
||||
|
||||
// Build map of local files
|
||||
localFiles := make(map[string]bool)
|
||||
for _, file := range projectCfg.Files {
|
||||
localFiles[file] = true
|
||||
}
|
||||
|
||||
// Collect all unique files
|
||||
allFiles := make(map[string]bool)
|
||||
for file := range localFiles {
|
||||
allFiles[file] = true
|
||||
}
|
||||
for file := range remoteFiles {
|
||||
allFiles[file] = true
|
||||
}
|
||||
|
||||
// Analyze each file
|
||||
var fileStatuses []types.FileStatus
|
||||
for file := range allFiles {
|
||||
status := analyzeFile(file, currentDir, localFiles[file], remoteFiles[file])
|
||||
fileStatuses = append(fileStatuses, status)
|
||||
}
|
||||
|
||||
// Print status
|
||||
fmt.Println()
|
||||
fmt.Printf("Project: %s\n", projectCfg.Project)
|
||||
fmt.Printf("Storage: s3://%s/%s\n", globalCfg.Storage.Bucket, projectCfg.Project)
|
||||
fmt.Println()
|
||||
fmt.Printf("Remote last updated: %s (from %s)\n", formatTime(metadata.UpdatedAt), metadata.Machine)
|
||||
fmt.Println()
|
||||
fmt.Printf(" %-30s %-12s %-12s %s\n", "FILE", "LOCAL", "REMOTE", "STATUS")
|
||||
|
||||
upToDate := 0
|
||||
modified := 0
|
||||
missing := 0
|
||||
newFiles := 0
|
||||
|
||||
for _, fs := range fileStatuses {
|
||||
localSizeStr := formatFileSize(fs.LocalSize)
|
||||
remoteSizeStr := formatFileSize(fs.RemoteSize)
|
||||
statusStr := formatStatus(fs.Status)
|
||||
|
||||
fmt.Printf(" %-30s %-12s %-12s %s\n", fs.Path, localSizeStr, remoteSizeStr, statusStr)
|
||||
|
||||
switch fs.Status {
|
||||
case types.StatusUpToDate:
|
||||
upToDate++
|
||||
case types.StatusLocalModified, types.StatusRemoteModified:
|
||||
modified++
|
||||
case types.StatusMissingLocal:
|
||||
missing++
|
||||
case types.StatusLocalOnly:
|
||||
newFiles++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
var summary []string
|
||||
if upToDate > 0 {
|
||||
summary = append(summary, fmt.Sprintf("%d up to date", upToDate))
|
||||
}
|
||||
if modified > 0 {
|
||||
summary = append(summary, fmt.Sprintf("%d modified", modified))
|
||||
}
|
||||
if missing > 0 {
|
||||
summary = append(summary, fmt.Sprintf("%d missing", missing))
|
||||
}
|
||||
if newFiles > 0 {
|
||||
summary = append(summary, fmt.Sprintf("%d new", newFiles))
|
||||
}
|
||||
|
||||
if len(summary) > 0 {
|
||||
fmt.Printf("Summary: %s\n", summary[0])
|
||||
for i := 1; i < len(summary); i++ {
|
||||
fmt.Printf(", %s", summary[i])
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// analyzeFile determines the status of a single file
|
||||
func analyzeFile(path string, rootDir string, existsLocal, existsRemote bool) types.FileStatus {
|
||||
fs := types.FileStatus{
|
||||
Path: path,
|
||||
LocalSize: -1,
|
||||
RemoteSize: -1,
|
||||
Status: types.StatusUpToDate,
|
||||
}
|
||||
|
||||
if !existsLocal && !existsRemote {
|
||||
// Should not happen
|
||||
fs.Status = types.StatusUpToDate
|
||||
return fs
|
||||
}
|
||||
|
||||
if existsLocal && !existsRemote {
|
||||
// Local only
|
||||
fs.Status = types.StatusLocalOnly
|
||||
fullPath := filepath.Join(rootDir, path)
|
||||
if info, err := os.Stat(fullPath); err == nil {
|
||||
fs.LocalSize = info.Size()
|
||||
fs.LocalHash = computeFileHash(fullPath)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
if !existsLocal && existsRemote {
|
||||
// Missing locally
|
||||
fs.Status = types.StatusMissingLocal
|
||||
// We don't have remote size info easily available without downloading
|
||||
return fs
|
||||
}
|
||||
|
||||
// Both exist - need to compare
|
||||
fullPath := filepath.Join(rootDir, path)
|
||||
if info, err := os.Stat(fullPath); err == nil {
|
||||
fs.LocalSize = info.Size()
|
||||
fs.LocalHash = computeFileHash(fullPath)
|
||||
} else {
|
||||
// File doesn't actually exist locally
|
||||
fs.Status = types.StatusMissingLocal
|
||||
return fs
|
||||
}
|
||||
|
||||
// For simplicity, we'll mark as up to date if file exists locally
|
||||
// In a real implementation, we'd need to compare hashes with remote
|
||||
fs.Status = types.StatusUpToDate
|
||||
|
||||
return fs
|
||||
}
|
||||
|
||||
// computeFileHash computes MD5 hash of a file
|
||||
func computeFileHash(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// formatFileSize formats file size for display
|
||||
func formatFileSize(size int64) string {
|
||||
if size < 0 {
|
||||
return "—"
|
||||
}
|
||||
if size < 1024 {
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
return fmt.Sprintf("%.1f KB", float64(size)/1024.0)
|
||||
}
|
||||
|
||||
// formatStatus formats status for display
|
||||
func formatStatus(status types.SyncStatus) string {
|
||||
switch status {
|
||||
case types.StatusUpToDate:
|
||||
return "✓ up to date"
|
||||
case types.StatusLocalModified:
|
||||
return "⚡ local modified"
|
||||
case types.StatusRemoteModified:
|
||||
return "⚡ remote modified"
|
||||
case types.StatusMissingLocal:
|
||||
return "✗ missing locally"
|
||||
case types.StatusLocalOnly:
|
||||
return "+ local only"
|
||||
case types.StatusConflict:
|
||||
return "⚠ conflict"
|
||||
default:
|
||||
return string(status)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user