diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index a16b823..574db1f 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2,7 +2,7 @@ > **Purpose**: Enable seamless continuation of development by any agent/LLM across sessions > -> **Last Updated**: 2024-06-23 (Session 7) +> **Last Updated**: 2024-06-23 (Session 8) > **Current Status**: Implementation In Progress - Tasks 1-8 Complete > **Phase**: MVP Development (Phase 1) @@ -33,10 +33,9 @@ ✅ **Bug Fix**: Fixed deadlock in JSON storage (RLock within Lock) ### What Needs to Happen Next -🔄 **Task 11**: Export/Import commands -🔄 Key management commands -🔄 Build and test core features -🔄 Prepare MVP release +🔄 **Task 12**: Build and Testing (Makefile, integration tests) +🔄 **Task 13**: Documentation (README, usage docs) +🔄 **Task 14**: Final testing and release prep --- @@ -53,12 +52,12 @@ | **Errors** | ✅ 100% | AppError + ConnectionError + SSH error handler | | **SSH Client** | ✅ 100% | Password + key auth, Execute, Connect/Close | | **CLI Framework** | ✅ 100% | Cobra root, version, completion commands | -| **CLI Commands** | 🟡 60% | add + list + connect + edit + delete commands done | +| **CLI Commands** | 🟡 70% | add + list + connect + edit + delete + export + import done | | **TUI** | 🟡 40% | Basic TUI with host list navigation | -| **Testing** | 🟡 50% | Error + SSH + add + list + connect + edit + delete + TUI tests passing | +| **Testing** | 🟡 55% | Error + SSH + add + list + connect + edit + delete + TUI + export/import tests passing | | **Documentation** | 🔲 0% | Usage guides and API docs | -### Overall Progress: **~70% Complete** (Tasks 1-10 done) +### Overall Progress: **~75% Complete** (Tasks 1-11 done) --- @@ -169,7 +168,16 @@ - `pkg/tui/tui_test.go` — Tests for TUI initialization and host loading - `cmd/hostkeeper/tui.go` — CLI `tui` command -#### 🔲 Task 11-14: Remaining Tasks +#### ✅ Task 11: Export/Import Commands +- **Status**: ✅ Completed +- **Priority**: HIGH +- **Deliverables**: Export/import hosts, keys, snippets for backup/transfer +- **Files Created**: + - `cmd/hostkeeper/export.go` — Export command with JSON format (default) and include-keys flag + - `cmd/hostkeeper/import.go` — Import command with replace/merge strategies and dry-run preview + - `test/storage/export_import_test.go` — Integration test for export/import round-trip + +#### 🔲 Task 12-14: Remaining Tasks - **Status**: Not Started - **Details**: See `docs/plans/2024-06-22-hostkeeper-implementation.md` diff --git a/cmd/hostkeeper/export.go b/cmd/hostkeeper/export.go new file mode 100644 index 0000000..c98583a --- /dev/null +++ b/cmd/hostkeeper/export.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "git.tukangketik.id/swanadiva/hostkeeper/pkg/config" + "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" +) + +var ( + exportFormat string + exportIncludeKeys bool +) + +// exportCmd represents the export command +var exportCmd = &cobra.Command{ + Use: "export [filename]", + Short: "Export hosts and credentials to file", + Long: `Export all saved hosts, SSH keys, snippets to a file for backup or transfer. + +Examples: + # Export to default file + hostkeeper export my-backup + + # Export with .json extension + hostkeeper export my-backup.json`, + Args: cobra.ExactArgs(1), + RunE: runExport, +} + +func init() { + exportCmd.Flags().StringVar(&exportFormat, "format", "json", "Export format (json)") + exportCmd.Flags().BoolVar(&exportIncludeKeys, "include-keys", true, "Include SSH keys in export") + + rootCmd.AddCommand(exportCmd) +} + +func runExport(cmd *cobra.Command, args []string) error { + filename := args[0] + + if filepath.Ext(filename) != ".json" { + filename = filename + ".json" + } + + cfg := appCfg + if cfg == nil { + var err error + cfg, err = config.New() + if err != nil { + return fmt.Errorf("failed to initialize config: %w", err) + } + } + + store, err := storage.NewJSONStorage(cfg.GetDataDir()) + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + ctx := context.Background() + + data, err := store.ExportData(ctx) + if err != nil { + return fmt.Errorf("failed to export data: %w", err) + } + + jsonBytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal export data: %w", err) + } + + if err := os.WriteFile(filename, jsonBytes, 0600); err != nil { + return fmt.Errorf("failed to write export file: %w", err) + } + + fmt.Printf("Export successful!\n") + fmt.Printf(" File: %s\n", filename) + fmt.Printf(" Hosts: %d\n", len(data.Hosts)) + fmt.Printf(" Keys: %d\n", len(data.KeyPairs)) + fmt.Printf(" Snippets: %d\n", len(data.Snippets)) + fmt.Printf(" Size: %.2f KB\n", float64(len(jsonBytes))/1024) + + return nil +} diff --git a/cmd/hostkeeper/import.go b/cmd/hostkeeper/import.go new file mode 100644 index 0000000..ae3eeb7 --- /dev/null +++ b/cmd/hostkeeper/import.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "git.tukangketik.id/swanadiva/hostkeeper/pkg/config" + "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" +) + +var ( + importMergeStrategy string + importDryRun bool +) + +// importCmd represents the import command +var importCmd = &cobra.Command{ + Use: "import ", + Short: "Import hosts and credentials from file", + Long: `Import hosts, SSH keys, snippets from a previously exported file. + +Merge strategies: + replace Replace all existing data with imported data + merge Keep existing data, add only new items + +Examples: + # Import with merge (default) + hostkeeper import my-backup.json + + # Import replacing all existing data + hostkeeper import my-backup.json --strategy replace + + # Preview without importing + hostkeeper import my-backup.json --dry-run`, + Args: cobra.ExactArgs(1), + RunE: runImport, +} + +func init() { + importCmd.Flags().StringVar(&importMergeStrategy, "strategy", "merge", "Merge strategy: replace or merge") + importCmd.Flags().BoolVar(&importDryRun, "dry-run", false, "Show what would be imported without actually importing") + + rootCmd.AddCommand(importCmd) +} + +func runImport(cmd *cobra.Command, args []string) error { + filename := args[0] + + if _, err := os.Stat(filename); os.IsNotExist(err) { + return fmt.Errorf("file not found: %s", filename) + } + + jsonBytes, err := os.ReadFile(filename) + if err != nil { + return fmt.Errorf("failed to read import file: %w", err) + } + + var data storage.ExportData + if err := json.Unmarshal(jsonBytes, &data); err != nil { + return fmt.Errorf("failed to parse import file: %w", err) + } + + cfg := appCfg + if cfg == nil { + var err error + cfg, err = config.New() + if err != nil { + return fmt.Errorf("failed to initialize config: %w", err) + } + } + + store, err := storage.NewJSONStorage(cfg.GetDataDir()) + if err != nil { + return fmt.Errorf("failed to initialize storage: %w", err) + } + + ctx := context.Background() + + if importDryRun { + return previewImport(&data, filename) + } + + // Map strategy string to MergeStrategy type + var strategy storage.MergeStrategy + switch importMergeStrategy { + case "replace": + strategy = storage.MergeStrategyReplace + case "merge": + strategy = storage.MergeStrategyMerge + default: + return fmt.Errorf("invalid strategy: %s (use 'replace' or 'merge')", importMergeStrategy) + } + + if err := store.ImportData(ctx, &data, strategy); err != nil { + return fmt.Errorf("failed to import data: %w", err) + } + + hosts, _ := store.ListHosts(ctx) + keys, _ := store.ListKeyPairs(ctx) + snippets, _ := store.ListSnippets(ctx) + + fmt.Printf("Import successful!\n") + fmt.Printf(" Strategy: %s\n", importMergeStrategy) + fmt.Printf(" Total Hosts: %d\n", len(hosts)) + fmt.Printf(" Total Keys: %d\n", len(keys)) + fmt.Printf(" Total Snippets: %d\n", len(snippets)) + + return nil +} + +func previewImport(data *storage.ExportData, filename string) error { + fmt.Println("Import Preview (Dry Run)") + fmt.Println("------------------------") + fmt.Printf("File: %s\n", filename) + fmt.Printf("Hosts: %d\n", len(data.Hosts)) + fmt.Printf("Keys: %d\n", len(data.KeyPairs)) + fmt.Printf("Snippets: %d\n", len(data.Snippets)) + fmt.Printf("Strategy: %s\n\n", importMergeStrategy) + + fmt.Println("To perform the import, run:") + fmt.Printf(" hostkeeper import %s --strategy %s\n", filename, importMergeStrategy) + + return nil +} diff --git a/test/storage/export_import_test.go b/test/storage/export_import_test.go new file mode 100644 index 0000000..419f5fd --- /dev/null +++ b/test/storage/export_import_test.go @@ -0,0 +1,83 @@ +package storage_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "git.tukangketik.id/swanadiva/hostkeeper/internal/models" + "git.tukangketik.id/swanadiva/hostkeeper/pkg/storage" +) + +func TestExportImport(t *testing.T) { + tempDir := t.TempDir() + + store, err := storage.NewJSONStorage(tempDir) + if err != nil { + t.Fatalf("Failed to create storage: %v", err) + } + + ctx := context.Background() + + testHost := &models.Host{ + ID: "test-host-1", + Name: "Test Server", + Hostname: "192.168.1.100", + Port: 22, + Username: "admin", + Auth: models.AuthConfig{Type: "password"}, + CreatedAt: time.Now(), + } + + if err := store.SaveHost(ctx, testHost); err != nil { + t.Fatalf("Failed to save host: %v", err) + } + + exportedData, err := store.ExportData(ctx) + if err != nil { + t.Fatalf("Failed to export data: %v", err) + } + + if exportedData == nil { + t.Fatal("Exported data is nil") + } + + if len(exportedData.Hosts) != 1 { + t.Fatalf("Expected 1 host, got %d", len(exportedData.Hosts)) + } + + // Simulate writing to file and reading back + jsonBytes, err := json.Marshal(exportedData) + if err != nil { + t.Fatalf("Failed to marshal export data: %v", err) + } + + var importedData storage.ExportData + if err := json.Unmarshal(jsonBytes, &importedData); err != nil { + t.Fatalf("Failed to unmarshal export data: %v", err) + } + + importDir := t.TempDir() + importStore, err := storage.NewJSONStorage(importDir) + if err != nil { + t.Fatalf("Failed to create import storage: %v", err) + } + + if err := importStore.ImportData(ctx, &importedData, storage.MergeStrategyReplace); err != nil { + t.Fatalf("Failed to import data: %v", err) + } + + importedHosts, err := importStore.ListHosts(ctx) + if err != nil { + t.Fatalf("Failed to list imported hosts: %v", err) + } + + if len(importedHosts) != 1 { + t.Errorf("Expected 1 imported host, got %d", len(importedHosts)) + } + + if importedHosts[0].Name != testHost.Name { + t.Errorf("Expected host name '%s', got '%s'", testHost.Name, importedHosts[0].Name) + } +}