init
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package archiver
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Create creates a tar.gz archive from the given files relative to rootDir
|
||||
// Returns: archive reader, archive size, error
|
||||
func Create(files []string, rootDir string) (io.Reader, int64, error) {
|
||||
var buf bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buf)
|
||||
tarWriter := tar.NewWriter(gzWriter)
|
||||
|
||||
for _, file := range files {
|
||||
// Get full path
|
||||
fullPath := filepath.Join(rootDir, file)
|
||||
|
||||
// Open file
|
||||
f, err := os.Open(fullPath)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to open file %s: %w", file, err)
|
||||
}
|
||||
|
||||
// Get file info
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return nil, 0, fmt.Errorf("failed to stat file %s: %w", file, err)
|
||||
}
|
||||
|
||||
// Create tar header
|
||||
header := &tar.Header{
|
||||
Name: file, // Use relative path
|
||||
Size: info.Size(),
|
||||
Mode: int64(info.Mode()),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
|
||||
// Write header
|
||||
if err := tarWriter.WriteHeader(header); err != nil {
|
||||
f.Close()
|
||||
return nil, 0, fmt.Errorf("failed to write tar header for %s: %w", file, err)
|
||||
}
|
||||
|
||||
// Write file content
|
||||
if _, err := io.Copy(tarWriter, f); err != nil {
|
||||
f.Close()
|
||||
return nil, 0, fmt.Errorf("failed to write file content for %s: %w", file, err)
|
||||
}
|
||||
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// Close tar writer
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to close tar writer: %w", err)
|
||||
}
|
||||
|
||||
// Close gzip writer
|
||||
if err := gzWriter.Close(); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to close gzip writer: %w", err)
|
||||
}
|
||||
|
||||
size := int64(buf.Len())
|
||||
return &buf, size, nil
|
||||
}
|
||||
|
||||
// Extract extracts a tar.gz archive to the destination directory
|
||||
// Returns: list of extracted files, error
|
||||
func Extract(archive io.Reader, destDir string) ([]string, error) {
|
||||
// Create gzip reader
|
||||
gzReader, err := gzip.NewReader(archive)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
// Create tar reader
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
var extractedFiles []string
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read tar header: %w", err)
|
||||
}
|
||||
|
||||
// Get target path
|
||||
targetPath := filepath.Join(destDir, header.Name)
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(targetPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
// Create file
|
||||
f, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
// Copy content
|
||||
if _, err := io.Copy(f, tarReader); err != nil {
|
||||
f.Close()
|
||||
return nil, fmt.Errorf("failed to write file content for %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
f.Close()
|
||||
extractedFiles = append(extractedFiles, header.Name)
|
||||
}
|
||||
|
||||
return extractedFiles, nil
|
||||
}
|
||||
|
||||
// List lists files in the archive without extracting
|
||||
func List(archive io.Reader) ([]string, error) {
|
||||
// Create gzip reader
|
||||
gzReader, err := gzip.NewReader(archive)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
// Create tar reader
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
|
||||
var files []string
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read tar header: %w", err)
|
||||
}
|
||||
|
||||
files = append(files, header.Name)
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package config
|
||||
|
||||
const (
|
||||
// DefaultConfigDir is the directory for global config
|
||||
DefaultConfigDir = ".config/aevs"
|
||||
|
||||
// DefaultGlobalConfigFile is the global config filename
|
||||
DefaultGlobalConfigFile = "config.yaml"
|
||||
|
||||
// DefaultProjectConfigFile is the project config filename
|
||||
DefaultProjectConfigFile = "aevs.yaml"
|
||||
|
||||
// DefaultStorageType is the default storage backend
|
||||
DefaultStorageType = "s3"
|
||||
|
||||
// DefaultRegion is the default AWS region
|
||||
DefaultRegion = "us-east-1"
|
||||
|
||||
// DefaultEndpoint is the default S3 endpoint
|
||||
DefaultEndpoint = "https://s3.amazonaws.com"
|
||||
|
||||
// ArchiveFileName is the name of the archive in storage
|
||||
ArchiveFileName = "envs.tar.gz"
|
||||
|
||||
// MetadataFileName is the name of the metadata file in storage
|
||||
MetadataFileName = "metadata.json"
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// GlobalConfig represents ~/.config/aevs/config.yaml
|
||||
type GlobalConfig struct {
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
}
|
||||
|
||||
// StorageConfig holds S3-compatible storage credentials
|
||||
type StorageConfig struct {
|
||||
Type string `yaml:"type"` // storage type: "s3"
|
||||
Endpoint string `yaml:"endpoint"` // S3 endpoint URL
|
||||
Region string `yaml:"region"` // AWS region (optional, default: us-east-1)
|
||||
Bucket string `yaml:"bucket"` // S3 bucket name
|
||||
AccessKey string `yaml:"access_key"` // AWS Access Key ID
|
||||
SecretKey string `yaml:"secret_key"` // AWS Secret Access Key
|
||||
}
|
||||
|
||||
// GlobalConfigPath returns the full path to the global config file
|
||||
func GlobalConfigPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, DefaultConfigDir, DefaultGlobalConfigFile)
|
||||
}
|
||||
|
||||
// GlobalConfigExists checks if the global config file exists
|
||||
func GlobalConfigExists() bool {
|
||||
path := GlobalConfigPath()
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// LoadGlobalConfig loads the global config from ~/.config/aevs/config.yaml
|
||||
func LoadGlobalConfig() (*GlobalConfig, error) {
|
||||
path := GlobalConfigPath()
|
||||
if path == "" {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg GlobalConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveGlobalConfig saves the global config to ~/.config/aevs/config.yaml with 0600 permissions
|
||||
func SaveGlobalConfig(cfg *GlobalConfig) error {
|
||||
path := GlobalConfigPath()
|
||||
if path == "" {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Marshal to YAML
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write with secure permissions (0600)
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ProjectConfig represents ./aevs.yaml
|
||||
type ProjectConfig struct {
|
||||
Project string `yaml:"project"` // unique project identifier
|
||||
Files []string `yaml:"files"` // list of file paths to sync
|
||||
}
|
||||
|
||||
// projectNameRegex validates project names (only a-z, 0-9, -, _)
|
||||
var projectNameRegex = regexp.MustCompile(`^[a-z0-9_-]+$`)
|
||||
|
||||
// LoadProjectConfig loads a project config from the specified path
|
||||
func LoadProjectConfig(path string) (*ProjectConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg ProjectConfig
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate project name
|
||||
if err := ValidateProjectName(cfg.Project); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// SaveProjectConfig saves a project config to the specified path
|
||||
func SaveProjectConfig(path string, cfg *ProjectConfig) error {
|
||||
// Validate project name before saving
|
||||
if err := ValidateProjectName(cfg.Project); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Marshal to YAML
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write file
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateProjectName checks if the project name contains only a-z, 0-9, -, _
|
||||
func ValidateProjectName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("project name cannot be empty")
|
||||
}
|
||||
|
||||
if !projectNameRegex.MatchString(name) {
|
||||
return fmt.Errorf("invalid project name %q; use only a-z, 0-9, -, _", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package errors
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Exit codes
|
||||
const (
|
||||
ExitSuccess = 0
|
||||
ExitGeneralError = 1
|
||||
ExitConfigError = 2
|
||||
ExitFileError = 3
|
||||
ExitStorageError = 4
|
||||
ExitNetworkError = 5
|
||||
ExitInterrupted = 130
|
||||
)
|
||||
|
||||
var (
|
||||
// Config errors
|
||||
ErrNoGlobalConfig = errors.New("no storage configured; run 'aevs config' first")
|
||||
ErrNoProjectConfig = errors.New("no aevs.yaml found; run 'aevs init' first")
|
||||
ErrConfigExists = errors.New("aevs.yaml already exists; use --force to overwrite")
|
||||
ErrInvalidProject = errors.New("invalid project name; use only a-z, 0-9, -, _")
|
||||
|
||||
// Storage errors
|
||||
ErrProjectNotFound = errors.New("project not found in storage")
|
||||
ErrAccessDenied = errors.New("access denied; check your credentials")
|
||||
ErrBucketNotFound = errors.New("bucket not found")
|
||||
|
||||
// File errors
|
||||
ErrFileNotFound = errors.New("file not found")
|
||||
ErrNoEnvFiles = errors.New("no env files found")
|
||||
)
|
||||
|
||||
// GetExitCode returns the appropriate exit code for an error
|
||||
func GetExitCode(err error) int {
|
||||
if err == nil {
|
||||
return ExitSuccess
|
||||
}
|
||||
|
||||
// Check for specific errors
|
||||
switch {
|
||||
case errors.Is(err, ErrNoGlobalConfig),
|
||||
errors.Is(err, ErrNoProjectConfig),
|
||||
errors.Is(err, ErrConfigExists),
|
||||
errors.Is(err, ErrInvalidProject):
|
||||
return ExitConfigError
|
||||
|
||||
case errors.Is(err, ErrFileNotFound),
|
||||
errors.Is(err, ErrNoEnvFiles),
|
||||
os.IsNotExist(err):
|
||||
return ExitFileError
|
||||
|
||||
case errors.Is(err, ErrProjectNotFound),
|
||||
errors.Is(err, ErrAccessDenied),
|
||||
errors.Is(err, ErrBucketNotFound):
|
||||
return ExitStorageError
|
||||
|
||||
case strings.Contains(err.Error(), "connection"),
|
||||
strings.Contains(err.Error(), "network"),
|
||||
strings.Contains(err.Error(), "timeout"):
|
||||
return ExitNetworkError
|
||||
|
||||
default:
|
||||
return ExitGeneralError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package scanner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IncludePatterns are patterns for env files to include
|
||||
var IncludePatterns = []string{
|
||||
".env",
|
||||
".env.*",
|
||||
"*.env",
|
||||
}
|
||||
|
||||
// ExcludeFiles are specific filenames to exclude
|
||||
var ExcludeFiles = []string{
|
||||
".env.example",
|
||||
".env.sample",
|
||||
".env.template",
|
||||
}
|
||||
|
||||
// ExcludeDirs are directories to skip when scanning
|
||||
var ExcludeDirs = []string{
|
||||
"node_modules",
|
||||
".git",
|
||||
"vendor",
|
||||
"venv",
|
||||
".venv",
|
||||
"__pycache__",
|
||||
".idea",
|
||||
".vscode",
|
||||
"dist",
|
||||
"build",
|
||||
}
|
||||
|
||||
// Scan recursively scans the directory for env files
|
||||
func Scan(rootDir string) ([]string, error) {
|
||||
var envFiles []string
|
||||
|
||||
err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip excluded directories
|
||||
if info.IsDir() {
|
||||
for _, excludeDir := range ExcludeDirs {
|
||||
if info.Name() == excludeDir {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if file should be excluded
|
||||
for _, excludeFile := range ExcludeFiles {
|
||||
if info.Name() == excludeFile {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file matches include patterns
|
||||
if matchesPattern(info.Name()) {
|
||||
// Get relative path from rootDir
|
||||
relPath, err := filepath.Rel(rootDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
envFiles = append(envFiles, relPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return envFiles, nil
|
||||
}
|
||||
|
||||
// matchesPattern checks if filename matches any include pattern
|
||||
func matchesPattern(filename string) bool {
|
||||
// Pattern: .env (exact match)
|
||||
if filename == ".env" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Pattern: .env.* (e.g., .env.production, .env.local)
|
||||
if strings.HasPrefix(filename, ".env.") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Pattern: *.env (e.g., docker.env, app.env)
|
||||
if strings.HasSuffix(filename, ".env") && filename != ".env" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
|
||||
"github.com/user/aevs/internal/config"
|
||||
)
|
||||
|
||||
// S3Storage implements Storage interface using AWS S3 (or compatible services)
|
||||
type S3Storage struct {
|
||||
client *s3.Client
|
||||
bucket string
|
||||
}
|
||||
|
||||
// NewS3Storage creates a new S3 storage instance with custom endpoint support
|
||||
func NewS3Storage(cfg *config.StorageConfig) (*S3Storage, error) {
|
||||
// Create AWS config with static credentials
|
||||
awsCfg := aws.Config{
|
||||
Region: cfg.Region,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(
|
||||
cfg.AccessKey,
|
||||
cfg.SecretKey,
|
||||
"",
|
||||
),
|
||||
}
|
||||
|
||||
// Create S3 client with custom endpoint for MinIO, R2, Spaces, etc.
|
||||
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
|
||||
if cfg.Endpoint != "" && cfg.Endpoint != config.DefaultEndpoint {
|
||||
o.BaseEndpoint = aws.String(cfg.Endpoint)
|
||||
o.UsePathStyle = true // Required for MinIO and some S3 alternatives
|
||||
}
|
||||
})
|
||||
|
||||
return &S3Storage{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Upload uploads data to S3
|
||||
func (s *S3Storage) Upload(ctx context.Context, key string, data io.Reader, size int64) error {
|
||||
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
Body: data,
|
||||
ContentLength: aws.Int64(size),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Download downloads data from S3
|
||||
func (s *S3Storage) Download(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Body, nil
|
||||
}
|
||||
|
||||
// Delete deletes an object from S3
|
||||
func (s *S3Storage) Delete(ctx context.Context, key string) error {
|
||||
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Exists checks if an object exists in S3
|
||||
func (s *S3Storage) Exists(ctx context.Context, key string) (bool, error) {
|
||||
_, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
// Check if it's a "not found" error
|
||||
var notFound *types.NotFound
|
||||
var noSuchKey *types.NoSuchKey
|
||||
if errors.As(err, ¬Found) || errors.As(err, &noSuchKey) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// List lists all objects with the given prefix
|
||||
func (s *S3Storage) List(ctx context.Context, prefix string) ([]string, error) {
|
||||
result, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var keys []string
|
||||
for _, obj := range result.Contents {
|
||||
if obj.Key != nil {
|
||||
keys = append(keys, *obj.Key)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// ListProjects returns all project directories (using delimiter)
|
||||
func (s *S3Storage) ListProjects(ctx context.Context) ([]string, error) {
|
||||
result, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(s.bucket),
|
||||
Delimiter: aws.String("/"),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var projects []string
|
||||
for _, prefix := range result.CommonPrefixes {
|
||||
if prefix.Prefix != nil {
|
||||
// Remove trailing slash
|
||||
projectName := strings.TrimSuffix(*prefix.Prefix, "/")
|
||||
projects = append(projects, projectName)
|
||||
}
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// TestConnection tests the connection to S3 by attempting to list buckets
|
||||
func (s *S3Storage) TestConnection(ctx context.Context) error {
|
||||
// Try to head the bucket to verify access
|
||||
_, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{
|
||||
Bucket: aws.String(s.bucket),
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Storage interface for cloud storage operations
|
||||
type Storage interface {
|
||||
// Upload uploads data to the specified key
|
||||
Upload(ctx context.Context, key string, data io.Reader, size int64) error
|
||||
|
||||
// Download downloads data from the specified key
|
||||
Download(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
|
||||
// Delete deletes the specified key
|
||||
Delete(ctx context.Context, key string) error
|
||||
|
||||
// Exists checks if the key exists
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
|
||||
// List lists all keys with the given prefix
|
||||
List(ctx context.Context, prefix string) ([]string, error)
|
||||
|
||||
// ListProjects returns all project directories
|
||||
ListProjects(ctx context.Context) ([]string, error)
|
||||
|
||||
// TestConnection tests the connection to storage
|
||||
TestConnection(ctx context.Context) error
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
// Metadata stored alongside the archive in S3
|
||||
type Metadata struct {
|
||||
UpdatedAt time.Time `json:"updated_at"` // last push timestamp
|
||||
Files []string `json:"files"` // list of files in archive
|
||||
Machine string `json:"machine"` // hostname of machine that pushed
|
||||
SizeBytes int64 `json:"size_bytes"` // archive size in bytes
|
||||
}
|
||||
|
||||
// ProjectInfo represents a project in storage (for listing)
|
||||
type ProjectInfo struct {
|
||||
Name string `json:"project"`
|
||||
FileCount int `json:"files"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// FileStatus represents the sync status of a single file
|
||||
type FileStatus struct {
|
||||
Path string `json:"path"`
|
||||
LocalSize int64 `json:"local_size"` // -1 if missing
|
||||
RemoteSize int64 `json:"remote_size"` // -1 if missing
|
||||
Status SyncStatus `json:"status"`
|
||||
LocalHash string `json:"local_hash"` // MD5 or SHA256
|
||||
RemoteHash string `json:"remote_hash"`
|
||||
}
|
||||
|
||||
// SyncStatus represents the synchronization state
|
||||
type SyncStatus string
|
||||
|
||||
const (
|
||||
StatusUpToDate SyncStatus = "up_to_date"
|
||||
StatusLocalModified SyncStatus = "local_modified"
|
||||
StatusRemoteModified SyncStatus = "remote_modified"
|
||||
StatusMissingLocal SyncStatus = "missing_local"
|
||||
StatusLocalOnly SyncStatus = "local_only"
|
||||
StatusConflict SyncStatus = "conflict" // both modified
|
||||
)
|
||||
Reference in New Issue
Block a user