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]) }