This commit is contained in:
naudachu
2026-01-28 16:24:21 +05:00
commit e10b389661
38 changed files with 5909 additions and 0 deletions
+120
View File
@@ -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
}
+176
View File
@@ -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
}
+173
View File
@@ -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])
}
+306
View File
@@ -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
}
+146
View File
@@ -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
}
+40
View File
@@ -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)
}
+275
View File
@@ -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)
}
}