# Wraith Desktop — Phase 1: Foundation Implementation Plan > **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Stand up the Wraith desktop application skeleton — Wails v3 + Vue 3 + SQLite + vault encryption + connection CRUD + dark UI shell — with validated spikes for multi-window and RDP frame transport. **Architecture:** Go backend exposes services (vault, connections, settings, themes) via Wails v3 bindings to a Vue 3 frontend running in WebView2. SQLite (WAL mode) stores all data in `%APPDATA%\Wraith\wraith.db`. Master password derives a 256-bit AES key via Argon2id. **Tech Stack:** Go 1.22+, Wails v3 (alpha), Vue 3 (Composition API), Pinia, Tailwind CSS, Naive UI, SQLite (modernc.org/sqlite), Argon2id, AES-256-GCM **Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` --- ## File Structure ``` wraith/ main.go # Wails app entry point, service registration go.mod # Go module go.sum Taskfile.yml # Wails v3 build tasks README.md # Developer documentation LICENSE # MIT license .gitignore build/ config.yml # Wails dev mode config windows/ Taskfile.yml # Windows build tasks internal/ db/ sqlite.go # SQLite open, WAL mode, busy_timeout sqlite_test.go migrations.go # Embedded SQL migrations runner migrations/ 001_initial.sql # Full schema vault/ service.go # Argon2id key derivation, AES-256-GCM encrypt/decrypt service_test.go connections/ service.go # Connection + group CRUD service_test.go search.go # Full-text search + tag filtering search_test.go settings/ service.go # Key-value settings CRUD service_test.go theme/ service.go # Theme CRUD service_test.go builtins.go # Dracula, Nord, Monokai, etc. session/ manager.go # Session manager (window-agnostic) session.go # Session types + interfaces plugin/ interfaces.go # ProtocolHandler, Importer interfaces registry.go # Plugin registration frontend/ index.html package.json vite.config.ts tsconfig.json tailwind.config.ts postcss.config.js src/ main.ts # Vue app bootstrap + Pinia + Naive UI App.vue # Root: unlock gate → main layout router.ts # Vue Router (/, /settings, /vault) layouts/ MainLayout.vue # Sidebar + tabs + status bar UnlockLayout.vue # Master password prompt components/ sidebar/ ConnectionTree.vue # Group tree with connection entries SidebarToggle.vue # Connections ↔ SFTP toggle session/ SessionContainer.vue # Holds active sessions (v-show) TabBar.vue # Draggable tab bar common/ StatusBar.vue # Bottom status bar stores/ app.store.ts # Unlocked state, settings, active theme connection.store.ts # Connections, groups, search session.store.ts # Active sessions, tab order composables/ useConnections.ts # Connection CRUD wrappers useVault.ts # Vault lock/unlock wrappers assets/ css/ main.css # Tailwind imports + dark theme vars ``` --- ## Task 1: Project Scaffold **Files:** - Create: `go.mod`, `main.go`, `Taskfile.yml`, `.gitignore`, `LICENSE` - Create: `frontend/package.json`, `frontend/vite.config.ts`, `frontend/tsconfig.json`, `frontend/index.html` - Create: `frontend/src/main.ts`, `frontend/src/App.vue` - [ ] **Step 1: Initialize Go module** ```bash cd /Users/vstockwell/repos/wraith go mod init github.com/vstockwell/wraith ``` - [ ] **Step 2: Install Wails v3 CLI (if not already installed)** ```bash go install github.com/wailsapp/wails/v3/cmd/wails3@latest ``` - [ ] **Step 3: Create `.gitignore`** ```gitignore # Go bin/ dist/ *.exe # Frontend frontend/node_modules/ frontend/dist/ frontend/bindings/ # Wails build/bin/ # IDE .vscode/ .idea/ # OS .DS_Store Thumbs.db # App data *.db *.db-wal *.db-shm # Superpowers .superpowers/ ``` - [ ] **Step 4: Create `LICENSE` (MIT)** ``` MIT License Copyright (c) 2026 Vantz Stockwell Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` - [ ] **Step 5: Create `main.go` — minimal Wails v3 app** ```go package main import ( "embed" "log" "github.com/wailsapp/wails/v3/pkg/application" ) //go:embed all:frontend/dist var assets embed.FS func main() { app := application.New(application.Options{ Name: "Wraith", Description: "SSH + RDP + SFTP Desktop Client", Services: []application.Service{}, Assets: application.AssetOptions{ Handler: application.BundledAssetFileServer(assets), }, }) app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ Title: "Wraith", Width: 1400, Height: 900, URL: "/", Windows: application.WindowsWindowOptions{ BackdropType: application.Mica, }, BackgroundColour: application.NewRGBA(13, 17, 23, 255), }) if err := app.Run(); err != nil { log.Fatal(err) } } ``` - [ ] **Step 6: Create `frontend/package.json`** ```json { "name": "wraith-frontend", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "vue-tsc --noEmit && vite build", "build:dev": "vue-tsc --noEmit && vite build --minify false --mode development", "preview": "vite preview" }, "dependencies": { "@wailsio/runtime": "latest", "vue": "^3.5.0", "vue-router": "^4.4.0", "pinia": "^2.2.0", "naive-ui": "^2.40.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.0", "typescript": "^5.5.0", "vite": "^6.0.0", "vue-tsc": "^2.0.0", "tailwindcss": "^4.0.0", "@tailwindcss/vite": "^4.0.0" } } ``` - [ ] **Step 7: Create `frontend/vite.config.ts`** ```ts import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import tailwindcss from "@tailwindcss/vite"; export default defineConfig({ plugins: [vue(), tailwindcss()], }); ``` - [ ] **Step 8: Create `frontend/tsconfig.json`** ```json { "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "bundler", "strict": true, "jsx": "preserve", "resolveJsonModule": true, "isolatedModules": true, "esModuleInterop": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, "noEmit": true, "paths": { "@/*": ["./src/*"] } }, "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"] } ``` - [ ] **Step 9: Create `frontend/index.html`** ```html Wraith
``` - [ ] **Step 10: Create `frontend/src/assets/css/main.css`** ```css @import "tailwindcss"; :root { --wraith-bg-primary: #0d1117; --wraith-bg-secondary: #161b22; --wraith-bg-tertiary: #21262d; --wraith-border: #30363d; --wraith-text-primary: #e0e0e0; --wraith-text-secondary: #8b949e; --wraith-text-muted: #484f58; --wraith-accent-blue: #58a6ff; --wraith-accent-green: #3fb950; --wraith-accent-red: #f85149; --wraith-accent-yellow: #e3b341; } body { margin: 0; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; background: var(--wraith-bg-primary); color: var(--wraith-text-primary); overflow: hidden; user-select: none; } /* Scrollbar styling */ ::-webkit-scrollbar { width: 8px; } ::-webkit-scrollbar-track { background: var(--wraith-bg-primary); } ::-webkit-scrollbar-thumb { background: var(--wraith-border); border-radius: 4px; } ::-webkit-scrollbar-thumb:hover { background: var(--wraith-text-muted); } ``` - [ ] **Step 11: Create `frontend/src/main.ts`** ```ts import { createApp } from "vue"; import { createPinia } from "pinia"; import App from "./App.vue"; import "./assets/css/main.css"; const app = createApp(App); app.use(createPinia()); app.mount("#app"); ``` - [ ] **Step 12: Create `frontend/src/App.vue` — placeholder** ```vue ``` - [ ] **Step 13: Install frontend dependencies and verify build** ```bash cd frontend && npm install && npm run build && cd .. ``` - [ ] **Step 14: Install Go dependencies and verify compilation** ```bash go mod tidy go build -o bin/wraith.exe . ``` - [ ] **Step 15: Commit scaffold** ```bash git add .gitignore LICENSE main.go go.mod go.sum Taskfile.yml frontend/ git commit -m "feat: Wails v3 + Vue 3 project scaffold with Tailwind dark theme" ``` --- ## Task 2: SQLite Database Layer **Files:** - Create: `internal/db/sqlite.go`, `internal/db/sqlite_test.go` - Create: `internal/db/migrations.go`, `internal/db/migrations/001_initial.sql` - [ ] **Step 1: Write the test — SQLite opens with WAL mode** ```go // internal/db/sqlite_test.go package db import ( "os" "path/filepath" "testing" ) func TestOpenCreatesDatabase(t *testing.T) { dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") db, err := Open(dbPath) if err != nil { t.Fatalf("Open() error: %v", err) } defer db.Close() // Verify file exists if _, err := os.Stat(dbPath); os.IsNotExist(err) { t.Fatal("database file was not created") } } func TestOpenSetsWALMode(t *testing.T) { dir := t.TempDir() db, err := Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("Open() error: %v", err) } defer db.Close() var mode string err = db.QueryRow("PRAGMA journal_mode").Scan(&mode) if err != nil { t.Fatalf("PRAGMA query error: %v", err) } if mode != "wal" { t.Errorf("journal_mode = %q, want %q", mode, "wal") } } func TestOpenSetsBusyTimeout(t *testing.T) { dir := t.TempDir() db, err := Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("Open() error: %v", err) } defer db.Close() var timeout int err = db.QueryRow("PRAGMA busy_timeout").Scan(&timeout) if err != nil { t.Fatalf("PRAGMA query error: %v", err) } if timeout != 5000 { t.Errorf("busy_timeout = %d, want %d", timeout, 5000) } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash go test ./internal/db/ -v ``` Expected: FAIL — `Open` not defined - [ ] **Step 3: Implement `internal/db/sqlite.go`** ```go package db import ( "database/sql" "fmt" "os" "path/filepath" _ "modernc.org/sqlite" ) func Open(dbPath string) (*sql.DB, error) { // Ensure parent directory exists dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0700); err != nil { return nil, fmt.Errorf("create db directory: %w", err) } db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, fmt.Errorf("open database: %w", err) } // Enable WAL mode for concurrent read/write if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { db.Close() return nil, fmt.Errorf("set WAL mode: %w", err) } // Set busy timeout to prevent "database is locked" errors if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil { db.Close() return nil, fmt.Errorf("set busy_timeout: %w", err) } // Enable foreign keys if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { db.Close() return nil, fmt.Errorf("enable foreign keys: %w", err) } return db, nil } ``` - [ ] **Step 4: Add `modernc.org/sqlite` dependency** ```bash go get modernc.org/sqlite go mod tidy ``` - [ ] **Step 5: Run tests to verify they pass** ```bash go test ./internal/db/ -v ``` Expected: 3 PASS - [ ] **Step 6: Create migration SQL — `internal/db/migrations/001_initial.sql`** Full schema from spec (all tables: groups, connections, credentials, ssh_keys, themes, connection_history, host_keys, settings). ```sql -- 001_initial.sql CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, sort_order INTEGER DEFAULT 0, icon TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS ssh_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, key_type TEXT, fingerprint TEXT, public_key TEXT, encrypted_private_key TEXT NOT NULL, passphrase_encrypted TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS credentials ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, username TEXT, domain TEXT, type TEXT NOT NULL CHECK(type IN ('password','ssh_key')), encrypted_value TEXT, ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS connections ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, hostname TEXT NOT NULL, port INTEGER NOT NULL DEFAULT 22, protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')), group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL, credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL, color TEXT, tags TEXT DEFAULT '[]', notes TEXT, options TEXT DEFAULT '{}', sort_order INTEGER DEFAULT 0, last_connected DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS themes ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, foreground TEXT NOT NULL, background TEXT NOT NULL, cursor TEXT NOT NULL, black TEXT NOT NULL, red TEXT NOT NULL, green TEXT NOT NULL, yellow TEXT NOT NULL, blue TEXT NOT NULL, magenta TEXT NOT NULL, cyan TEXT NOT NULL, white TEXT NOT NULL, bright_black TEXT NOT NULL, bright_red TEXT NOT NULL, bright_green TEXT NOT NULL, bright_yellow TEXT NOT NULL, bright_blue TEXT NOT NULL, bright_magenta TEXT NOT NULL, bright_cyan TEXT NOT NULL, bright_white TEXT NOT NULL, selection_bg TEXT, selection_fg TEXT, is_builtin BOOLEAN DEFAULT 0 ); CREATE TABLE IF NOT EXISTS connection_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE, protocol TEXT NOT NULL, connected_at DATETIME DEFAULT CURRENT_TIMESTAMP, disconnected_at DATETIME, duration_secs INTEGER ); CREATE TABLE IF NOT EXISTS host_keys ( hostname TEXT NOT NULL, port INTEGER NOT NULL, key_type TEXT NOT NULL, fingerprint TEXT NOT NULL, raw_key TEXT, first_seen DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (hostname, port, key_type) ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); ``` - [ ] **Step 7: Write migration runner test** ```go // Add to internal/db/sqlite_test.go func TestMigrateCreatesAllTables(t *testing.T) { dir := t.TempDir() db, err := Open(filepath.Join(dir, "test.db")) if err != nil { t.Fatalf("Open() error: %v", err) } defer db.Close() if err := Migrate(db); err != nil { t.Fatalf("Migrate() error: %v", err) } expectedTables := []string{ "groups", "connections", "credentials", "ssh_keys", "themes", "connection_history", "host_keys", "settings", } for _, table := range expectedTables { var name string err := db.QueryRow( "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table, ).Scan(&name) if err != nil { t.Errorf("table %q not found: %v", table, err) } } } ``` - [ ] **Step 8: Implement migration runner — `internal/db/migrations.go`** ```go package db import ( "database/sql" "embed" "fmt" "sort" ) //go:embed migrations/*.sql var migrationFiles embed.FS func Migrate(db *sql.DB) error { entries, err := migrationFiles.ReadDir("migrations") if err != nil { return fmt.Errorf("read migrations: %w", err) } sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) for _, entry := range entries { content, err := migrationFiles.ReadFile("migrations/" + entry.Name()) if err != nil { return fmt.Errorf("read migration %s: %w", entry.Name(), err) } if _, err := db.Exec(string(content)); err != nil { return fmt.Errorf("execute migration %s: %w", entry.Name(), err) } } return nil } ``` - [ ] **Step 9: Run all db tests** ```bash go test ./internal/db/ -v ``` Expected: 4 PASS - [ ] **Step 10: Commit** ```bash git add internal/db/ git commit -m "feat: SQLite database layer with WAL mode and schema migrations" ``` --- ## Task 3: Vault Service — Encryption **Files:** - Create: `internal/vault/service.go`, `internal/vault/service_test.go` - [ ] **Step 1: Write test — Argon2id key derivation produces consistent keys** ```go // internal/vault/service_test.go package vault import ( "testing" ) func TestDeriveKeyConsistent(t *testing.T) { salt := []byte("test-salt-exactly-32-bytes-long!") key1 := DeriveKey("mypassword", salt) key2 := DeriveKey("mypassword", salt) if len(key1) != 32 { t.Errorf("key length = %d, want 32", len(key1)) } if string(key1) != string(key2) { t.Error("same password+salt produced different keys") } } func TestDeriveKeyDifferentPasswords(t *testing.T) { salt := []byte("test-salt-exactly-32-bytes-long!") key1 := DeriveKey("password1", salt) key2 := DeriveKey("password2", salt) if string(key1) == string(key2) { t.Error("different passwords produced same key") } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash go test ./internal/vault/ -v ``` Expected: FAIL — `DeriveKey` not defined - [ ] **Step 3: Implement key derivation** ```go // internal/vault/service.go package vault import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/hex" "errors" "fmt" "strings" "golang.org/x/crypto/argon2" ) // Argon2id parameters (OWASP recommended) const ( argonTime = 3 argonMemory = 64 * 1024 // 64MB argonThreads = 4 argonKeyLen = 32 ) // DeriveKey derives a 256-bit key from password and salt using Argon2id. func DeriveKey(password string, salt []byte) []byte { return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash go get golang.org/x/crypto go test ./internal/vault/ -v ``` Expected: 2 PASS - [ ] **Step 5: Write test — Encrypt/Decrypt round-trip** ```go // Add to internal/vault/service_test.go func TestEncryptDecryptRoundTrip(t *testing.T) { key := DeriveKey("testpassword", []byte("test-salt-32-bytes-long-exactly!")) vs := NewVaultService(key) plaintext := "super-secret-ssh-key-data" encrypted, err := vs.Encrypt(plaintext) if err != nil { t.Fatalf("Encrypt() error: %v", err) } // Must start with v1: prefix if !strings.HasPrefix(encrypted, "v1:") { t.Errorf("encrypted does not start with v1: prefix: %q", encrypted[:10]) } decrypted, err := vs.Decrypt(encrypted) if err != nil { t.Fatalf("Decrypt() error: %v", err) } if decrypted != plaintext { t.Errorf("Decrypt() = %q, want %q", decrypted, plaintext) } } func TestEncryptProducesDifferentCiphertexts(t *testing.T) { key := DeriveKey("testpassword", []byte("test-salt-32-bytes-long-exactly!")) vs := NewVaultService(key) enc1, _ := vs.Encrypt("same-data") enc2, _ := vs.Encrypt("same-data") if enc1 == enc2 { t.Error("two encryptions of same data produced identical ciphertext (IV reuse)") } } func TestDecryptWrongKey(t *testing.T) { key1 := DeriveKey("password1", []byte("test-salt-32-bytes-long-exactly!")) key2 := DeriveKey("password2", []byte("test-salt-32-bytes-long-exactly!")) vs1 := NewVaultService(key1) vs2 := NewVaultService(key2) encrypted, _ := vs1.Encrypt("secret") _, err := vs2.Decrypt(encrypted) if err == nil { t.Error("Decrypt() with wrong key should return error") } } func TestDecryptInvalidFormat(t *testing.T) { key := DeriveKey("test", []byte("test-salt-32-bytes-long-exactly!")) vs := NewVaultService(key) _, err := vs.Decrypt("not-valid-format") if err == nil { t.Error("Decrypt() with invalid format should return error") } } ``` - [ ] **Step 6: Implement Encrypt/Decrypt** ```go // Add to internal/vault/service.go // VaultService handles encryption and decryption of sensitive data. type VaultService struct { key []byte } // NewVaultService creates a vault with the given AES-256 key. func NewVaultService(key []byte) *VaultService { return &VaultService{key: key} } // Encrypt encrypts plaintext using AES-256-GCM. // Returns "v1:{iv_hex}:{sealed_hex}" where sealed = ciphertext || authTag. func (v *VaultService) Encrypt(plaintext string) (string, error) { block, err := aes.NewCipher(v.key) if err != nil { return "", fmt.Errorf("create cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("create GCM: %w", err) } iv := make([]byte, gcm.NonceSize()) // 12 bytes if _, err := rand.Read(iv); err != nil { return "", fmt.Errorf("generate IV: %w", err) } sealed := gcm.Seal(nil, iv, []byte(plaintext), nil) return fmt.Sprintf("v1:%s:%s", hex.EncodeToString(iv), hex.EncodeToString(sealed)), nil } // Decrypt decrypts a "v1:{iv_hex}:{sealed_hex}" string. func (v *VaultService) Decrypt(encrypted string) (string, error) { parts := strings.SplitN(encrypted, ":", 3) if len(parts) != 3 || parts[0] != "v1" { return "", errors.New("invalid encrypted format: expected v1:{iv}:{sealed}") } iv, err := hex.DecodeString(parts[1]) if err != nil { return "", fmt.Errorf("decode IV: %w", err) } sealed, err := hex.DecodeString(parts[2]) if err != nil { return "", fmt.Errorf("decode sealed data: %w", err) } block, err := aes.NewCipher(v.key) if err != nil { return "", fmt.Errorf("create cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return "", fmt.Errorf("create GCM: %w", err) } plaintext, err := gcm.Open(nil, iv, sealed, nil) if err != nil { return "", fmt.Errorf("decrypt: %w", err) } return string(plaintext), nil } ``` - [ ] **Step 7: Run all vault tests** ```bash go test ./internal/vault/ -v ``` Expected: 6 PASS - [ ] **Step 8: Write test — GenerateSalt produces 32 random bytes** ```go // Add to internal/vault/service_test.go func TestGenerateSalt(t *testing.T) { salt1, err := GenerateSalt() if err != nil { t.Fatalf("GenerateSalt() error: %v", err) } if len(salt1) != 32 { t.Errorf("salt length = %d, want 32", len(salt1)) } salt2, _ := GenerateSalt() if string(salt1) == string(salt2) { t.Error("two calls to GenerateSalt produced identical salt") } } ``` - [ ] **Step 9: Implement GenerateSalt** ```go // Add to internal/vault/service.go // GenerateSalt generates a 32-byte random salt for Argon2id. func GenerateSalt() ([]byte, error) { salt := make([]byte, 32) if _, err := rand.Read(salt); err != nil { return nil, fmt.Errorf("generate salt: %w", err) } return salt, nil } ``` - [ ] **Step 10: Run all vault tests** ```bash go test ./internal/vault/ -v ``` Expected: 7 PASS - [ ] **Step 11: Commit** ```bash git add internal/vault/ git commit -m "feat: vault service — Argon2id key derivation + AES-256-GCM encrypt/decrypt" ``` --- ## Task 4: Settings Service **Files:** - Create: `internal/settings/service.go`, `internal/settings/service_test.go` - [ ] **Step 1: Write tests** ```go // internal/settings/service_test.go package settings import ( "path/filepath" "testing" "github.com/vstockwell/wraith/internal/db" ) func setupTestDB(t *testing.T) *SettingsService { t.Helper() d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } if err := db.Migrate(d); err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) return NewSettingsService(d) } func TestSetAndGet(t *testing.T) { s := setupTestDB(t) if err := s.Set("theme", "dracula"); err != nil { t.Fatalf("Set() error: %v", err) } val, err := s.Get("theme") if err != nil { t.Fatalf("Get() error: %v", err) } if val != "dracula" { t.Errorf("Get() = %q, want %q", val, "dracula") } } func TestGetMissing(t *testing.T) { s := setupTestDB(t) val, err := s.Get("nonexistent") if err != nil { t.Fatalf("Get() error: %v", err) } if val != "" { t.Errorf("Get() = %q, want empty string", val) } } func TestSetOverwrites(t *testing.T) { s := setupTestDB(t) s.Set("key", "value1") s.Set("key", "value2") val, _ := s.Get("key") if val != "value2" { t.Errorf("Get() = %q, want %q", val, "value2") } } func TestGetWithDefault(t *testing.T) { s := setupTestDB(t) val := s.GetDefault("missing", "fallback") if val != "fallback" { t.Errorf("GetDefault() = %q, want %q", val, "fallback") } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash go test ./internal/settings/ -v ``` - [ ] **Step 3: Implement settings service** ```go // internal/settings/service.go package settings import "database/sql" type SettingsService struct { db *sql.DB } func NewSettingsService(db *sql.DB) *SettingsService { return &SettingsService{db: db} } func (s *SettingsService) Get(key string) (string, error) { var value string err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) if err == sql.ErrNoRows { return "", nil } return value, err } func (s *SettingsService) GetDefault(key, defaultValue string) string { val, err := s.Get(key) if err != nil || val == "" { return defaultValue } return val } func (s *SettingsService) Set(key, value string) error { _, err := s.db.Exec( "INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", key, value, value, ) return err } func (s *SettingsService) Delete(key string) error { _, err := s.db.Exec("DELETE FROM settings WHERE key = ?", key) return err } ``` - [ ] **Step 4: Run tests** ```bash go test ./internal/settings/ -v ``` Expected: 4 PASS - [ ] **Step 5: Commit** ```bash git add internal/settings/ git commit -m "feat: settings service — key-value store with upsert" ``` --- ## Task 5: Connection + Group CRUD **Files:** - Create: `internal/connections/service.go`, `internal/connections/service_test.go` - [ ] **Step 1: Write connection model types** ```go // internal/connections/service.go package connections import ( "database/sql" "encoding/json" "fmt" "time" ) type Group struct { ID int64 `json:"id"` Name string `json:"name"` ParentID *int64 `json:"parentId"` SortOrder int `json:"sortOrder"` Icon string `json:"icon"` CreatedAt time.Time `json:"createdAt"` Children []Group `json:"children,omitempty"` } type Connection struct { ID int64 `json:"id"` Name string `json:"name"` Hostname string `json:"hostname"` Port int `json:"port"` Protocol string `json:"protocol"` GroupID *int64 `json:"groupId"` CredentialID *int64 `json:"credentialId"` Color string `json:"color"` Tags []string `json:"tags"` Notes string `json:"notes"` Options string `json:"options"` SortOrder int `json:"sortOrder"` LastConnected *time.Time `json:"lastConnected"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type ConnectionService struct { db *sql.DB } func NewConnectionService(db *sql.DB) *ConnectionService { return &ConnectionService{db: db} } ``` - [ ] **Step 2: Write tests for group CRUD** ```go // internal/connections/service_test.go package connections import ( "path/filepath" "testing" "github.com/vstockwell/wraith/internal/db" ) func setupTestDB(t *testing.T) *ConnectionService { t.Helper() d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } if err := db.Migrate(d); err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) return NewConnectionService(d) } func TestCreateGroup(t *testing.T) { svc := setupTestDB(t) g, err := svc.CreateGroup("Vantz's Stuff", nil) if err != nil { t.Fatalf("CreateGroup() error: %v", err) } if g.ID == 0 { t.Error("group ID should not be zero") } if g.Name != "Vantz's Stuff" { t.Errorf("Name = %q, want %q", g.Name, "Vantz's Stuff") } } func TestCreateSubGroup(t *testing.T) { svc := setupTestDB(t) parent, _ := svc.CreateGroup("Parent", nil) parentID := parent.ID child, err := svc.CreateGroup("Child", &parentID) if err != nil { t.Fatalf("CreateGroup() error: %v", err) } if child.ParentID == nil || *child.ParentID != parent.ID { t.Error("child ParentID should match parent ID") } } func TestListGroups(t *testing.T) { svc := setupTestDB(t) svc.CreateGroup("Group A", nil) svc.CreateGroup("Group B", nil) groups, err := svc.ListGroups() if err != nil { t.Fatalf("ListGroups() error: %v", err) } if len(groups) != 2 { t.Errorf("len(groups) = %d, want 2", len(groups)) } } func TestDeleteGroup(t *testing.T) { svc := setupTestDB(t) g, _ := svc.CreateGroup("ToDelete", nil) if err := svc.DeleteGroup(g.ID); err != nil { t.Fatalf("DeleteGroup() error: %v", err) } groups, _ := svc.ListGroups() if len(groups) != 0 { t.Error("group should have been deleted") } } ``` - [ ] **Step 3: Implement group CRUD methods** ```go // Add to internal/connections/service.go func (s *ConnectionService) CreateGroup(name string, parentID *int64) (*Group, error) { result, err := s.db.Exec( "INSERT INTO groups (name, parent_id) VALUES (?, ?)", name, parentID, ) if err != nil { return nil, fmt.Errorf("create group: %w", err) } id, _ := result.LastInsertId() return &Group{ID: id, Name: name, ParentID: parentID}, nil } func (s *ConnectionService) ListGroups() ([]Group, error) { rows, err := s.db.Query("SELECT id, name, parent_id, sort_order, COALESCE(icon,''), created_at FROM groups ORDER BY sort_order, name") if err != nil { return nil, fmt.Errorf("list groups: %w", err) } defer rows.Close() var groups []Group for rows.Next() { var g Group if err := rows.Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &g.Icon, &g.CreatedAt); err != nil { return nil, err } groups = append(groups, g) } return groups, nil } func (s *ConnectionService) DeleteGroup(id int64) error { _, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id) return err } ``` - [ ] **Step 4: Run group tests** ```bash go test ./internal/connections/ -v -run "TestCreateGroup|TestCreateSubGroup|TestListGroups|TestDeleteGroup" ``` Expected: 4 PASS - [ ] **Step 5: Write tests for connection CRUD** ```go // Add to internal/connections/service_test.go func TestCreateConnection(t *testing.T) { svc := setupTestDB(t) conn, err := svc.CreateConnection(CreateConnectionInput{ Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}, }) if err != nil { t.Fatalf("CreateConnection() error: %v", err) } if conn.Name != "Asgard" { t.Errorf("Name = %q, want %q", conn.Name, "Asgard") } if len(conn.Tags) != 2 { t.Errorf("len(Tags) = %d, want 2", len(conn.Tags)) } } func TestListConnections(t *testing.T) { svc := setupTestDB(t) svc.CreateConnection(CreateConnectionInput{Name: "Host1", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"}) svc.CreateConnection(CreateConnectionInput{Name: "Host2", Hostname: "10.0.0.2", Port: 3389, Protocol: "rdp"}) conns, err := svc.ListConnections() if err != nil { t.Fatalf("ListConnections() error: %v", err) } if len(conns) != 2 { t.Errorf("len(conns) = %d, want 2", len(conns)) } } func TestUpdateConnection(t *testing.T) { svc := setupTestDB(t) conn, _ := svc.CreateConnection(CreateConnectionInput{Name: "Old", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"}) updated, err := svc.UpdateConnection(conn.ID, UpdateConnectionInput{Name: strPtr("New")}) if err != nil { t.Fatalf("UpdateConnection() error: %v", err) } if updated.Name != "New" { t.Errorf("Name = %q, want %q", updated.Name, "New") } } func TestDeleteConnection(t *testing.T) { svc := setupTestDB(t) conn, _ := svc.CreateConnection(CreateConnectionInput{Name: "Del", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh"}) if err := svc.DeleteConnection(conn.ID); err != nil { t.Fatalf("DeleteConnection() error: %v", err) } conns, _ := svc.ListConnections() if len(conns) != 0 { t.Error("connection should have been deleted") } } func strPtr(s string) *string { return &s } ``` - [ ] **Step 6: Implement connection CRUD** ```go // Add to internal/connections/service.go type CreateConnectionInput struct { Name string `json:"name"` Hostname string `json:"hostname"` Port int `json:"port"` Protocol string `json:"protocol"` GroupID *int64 `json:"groupId"` CredentialID *int64 `json:"credentialId"` Color string `json:"color"` Tags []string `json:"tags"` Notes string `json:"notes"` Options string `json:"options"` } type UpdateConnectionInput struct { Name *string `json:"name"` Hostname *string `json:"hostname"` Port *int `json:"port"` GroupID *int64 `json:"groupId"` CredentialID *int64 `json:"credentialId"` Color *string `json:"color"` Tags []string `json:"tags"` Notes *string `json:"notes"` Options *string `json:"options"` } func (s *ConnectionService) CreateConnection(input CreateConnectionInput) (*Connection, error) { tags, _ := json.Marshal(input.Tags) if input.Tags == nil { tags = []byte("[]") } options := input.Options if options == "" { options = "{}" } result, err := s.db.Exec( `INSERT INTO connections (name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, input.Name, input.Hostname, input.Port, input.Protocol, input.GroupID, input.CredentialID, input.Color, string(tags), input.Notes, options, ) if err != nil { return nil, fmt.Errorf("create connection: %w", err) } id, _ := result.LastInsertId() return s.GetConnection(id) } func (s *ConnectionService) GetConnection(id int64) (*Connection, error) { var conn Connection var tagsStr string err := s.db.QueryRow( `SELECT id, name, hostname, port, protocol, group_id, credential_id, COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'), sort_order, last_connected, created_at, updated_at FROM connections WHERE id = ?`, id, ).Scan(&conn.ID, &conn.Name, &conn.Hostname, &conn.Port, &conn.Protocol, &conn.GroupID, &conn.CredentialID, &conn.Color, &tagsStr, &conn.Notes, &conn.Options, &conn.SortOrder, &conn.LastConnected, &conn.CreatedAt, &conn.UpdatedAt) if err != nil { return nil, fmt.Errorf("get connection: %w", err) } json.Unmarshal([]byte(tagsStr), &conn.Tags) return &conn, nil } func (s *ConnectionService) ListConnections() ([]Connection, error) { rows, err := s.db.Query( `SELECT id, name, hostname, port, protocol, group_id, credential_id, COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'), sort_order, last_connected, created_at, updated_at FROM connections ORDER BY sort_order, name`) if err != nil { return nil, fmt.Errorf("list connections: %w", err) } defer rows.Close() var conns []Connection for rows.Next() { var c Connection var tagsStr string if err := rows.Scan(&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol, &c.GroupID, &c.CredentialID, &c.Color, &tagsStr, &c.Notes, &c.Options, &c.SortOrder, &c.LastConnected, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } json.Unmarshal([]byte(tagsStr), &c.Tags) conns = append(conns, c) } return conns, nil } func (s *ConnectionService) UpdateConnection(id int64, input UpdateConnectionInput) (*Connection, error) { setClauses := []string{"updated_at = CURRENT_TIMESTAMP"} args := []interface{}{} if input.Name != nil { setClauses = append(setClauses, "name = ?") args = append(args, *input.Name) } if input.Hostname != nil { setClauses = append(setClauses, "hostname = ?") args = append(args, *input.Hostname) } if input.Port != nil { setClauses = append(setClauses, "port = ?") args = append(args, *input.Port) } if input.Tags != nil { tags, _ := json.Marshal(input.Tags) setClauses = append(setClauses, "tags = ?") args = append(args, string(tags)) } if input.Notes != nil { setClauses = append(setClauses, "notes = ?") args = append(args, *input.Notes) } if input.Color != nil { setClauses = append(setClauses, "color = ?") args = append(args, *input.Color) } if input.Options != nil { setClauses = append(setClauses, "options = ?") args = append(args, *input.Options) } args = append(args, id) query := fmt.Sprintf("UPDATE connections SET %s WHERE id = ?", strings.Join(setClauses, ", ")) if _, err := s.db.Exec(query, args...); err != nil { return nil, fmt.Errorf("update connection: %w", err) } return s.GetConnection(id) } func (s *ConnectionService) DeleteConnection(id int64) error { _, err := s.db.Exec("DELETE FROM connections WHERE id = ?", id) return err } ``` - [ ] **Step 7: Run all connection tests** ```bash go test ./internal/connections/ -v ``` Expected: 8 PASS (4 group + 4 connection) - [ ] **Step 8: Commit** ```bash git add internal/connections/ git commit -m "feat: connection + group CRUD with JSON tags and options" ``` --- ## Task 6: Connection Search + Tag Filtering **Files:** - Create: `internal/connections/search.go`, `internal/connections/search_test.go` - [ ] **Step 1: Write search tests** ```go // internal/connections/search_test.go package connections import "testing" func TestSearchByName(t *testing.T) { svc := setupTestDB(t) svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"}) svc.CreateConnection(CreateConnectionInput{Name: "Docker", Hostname: "155.254.29.221", Port: 22, Protocol: "ssh"}) results, err := svc.Search("asg") if err != nil { t.Fatalf("Search() error: %v", err) } if len(results) != 1 { t.Fatalf("len(results) = %d, want 1", len(results)) } if results[0].Name != "Asgard" { t.Errorf("Name = %q, want %q", results[0].Name, "Asgard") } } func TestSearchByHostname(t *testing.T) { svc := setupTestDB(t) svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"}) results, _ := svc.Search("192.168") if len(results) != 1 { t.Errorf("len(results) = %d, want 1", len(results)) } } func TestSearchByTag(t *testing.T) { svc := setupTestDB(t) svc.CreateConnection(CreateConnectionInput{Name: "ProdServer", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}}) svc.CreateConnection(CreateConnectionInput{Name: "DevServer", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev", "Linux"}}) results, _ := svc.Search("Prod") if len(results) != 1 { t.Errorf("len(results) = %d, want 1", len(results)) } } func TestFilterByTag(t *testing.T) { svc := setupTestDB(t) svc.CreateConnection(CreateConnectionInput{Name: "A", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod"}}) svc.CreateConnection(CreateConnectionInput{Name: "B", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev"}}) svc.CreateConnection(CreateConnectionInput{Name: "C", Hostname: "10.0.0.3", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}}) results, _ := svc.FilterByTag("Prod") if len(results) != 2 { t.Errorf("len(results) = %d, want 2", len(results)) } } ``` - [ ] **Step 2: Implement search** ```go // internal/connections/search.go package connections import "fmt" func (s *ConnectionService) Search(query string) ([]Connection, error) { like := "%" + query + "%" rows, err := s.db.Query( `SELECT id, name, hostname, port, protocol, group_id, credential_id, COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'), sort_order, last_connected, created_at, updated_at FROM connections WHERE name LIKE ? COLLATE NOCASE OR hostname LIKE ? COLLATE NOCASE OR tags LIKE ? COLLATE NOCASE OR notes LIKE ? COLLATE NOCASE ORDER BY last_connected DESC NULLS LAST, name`, like, like, like, like, ) if err != nil { return nil, fmt.Errorf("search connections: %w", err) } defer rows.Close() return scanConnections(rows) } func (s *ConnectionService) FilterByTag(tag string) ([]Connection, error) { rows, err := s.db.Query( `SELECT c.id, c.name, c.hostname, c.port, c.protocol, c.group_id, c.credential_id, COALESCE(c.color,''), c.tags, COALESCE(c.notes,''), COALESCE(c.options,'{}'), c.sort_order, c.last_connected, c.created_at, c.updated_at FROM connections c, json_each(c.tags) AS t WHERE t.value = ? ORDER BY c.name`, tag, ) if err != nil { return nil, fmt.Errorf("filter by tag: %w", err) } defer rows.Close() return scanConnections(rows) } ``` - [ ] **Step 3: Extract `scanConnections` helper to `service.go`** ```go // Add to internal/connections/service.go import "database/sql" func scanConnections(rows *sql.Rows) ([]Connection, error) { var conns []Connection for rows.Next() { var c Connection var tagsStr string if err := rows.Scan(&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol, &c.GroupID, &c.CredentialID, &c.Color, &tagsStr, &c.Notes, &c.Options, &c.SortOrder, &c.LastConnected, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } json.Unmarshal([]byte(tagsStr), &c.Tags) conns = append(conns, c) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("scan connections: %w", err) } return conns, nil } ``` Refactor `ListConnections` to use `scanConnections`. - [ ] **Step 4: Run search tests** ```bash go test ./internal/connections/ -v ``` Expected: 12 PASS - [ ] **Step 5: Commit** ```bash git add internal/connections/ git commit -m "feat: connection search by name/hostname/tag with json_each filtering" ``` --- ## Task 7: Theme Service + Built-in Themes **Files:** - Create: `internal/theme/service.go`, `internal/theme/service_test.go`, `internal/theme/builtins.go` - [ ] **Step 1: Define theme type and built-in themes** ```go // internal/theme/builtins.go package theme type Theme struct { ID int64 `json:"id"` Name string `json:"name"` Foreground string `json:"foreground"` Background string `json:"background"` Cursor string `json:"cursor"` Black string `json:"black"` Red string `json:"red"` Green string `json:"green"` Yellow string `json:"yellow"` Blue string `json:"blue"` Magenta string `json:"magenta"` Cyan string `json:"cyan"` White string `json:"white"` BrightBlack string `json:"brightBlack"` BrightRed string `json:"brightRed"` BrightGreen string `json:"brightGreen"` BrightYellow string `json:"brightYellow"` BrightBlue string `json:"brightBlue"` BrightMagenta string `json:"brightMagenta"` BrightCyan string `json:"brightCyan"` BrightWhite string `json:"brightWhite"` SelectionBg string `json:"selectionBg,omitempty"` SelectionFg string `json:"selectionFg,omitempty"` IsBuiltin bool `json:"isBuiltin"` } var BuiltinThemes = []Theme{ { Name: "Dracula", IsBuiltin: true, Foreground: "#f8f8f2", Background: "#282a36", Cursor: "#f8f8f2", Black: "#21222c", Red: "#ff5555", Green: "#50fa7b", Yellow: "#f1fa8c", Blue: "#bd93f9", Magenta: "#ff79c6", Cyan: "#8be9fd", White: "#f8f8f2", BrightBlack: "#6272a4", BrightRed: "#ff6e6e", BrightGreen: "#69ff94", BrightYellow: "#ffffa5", BrightBlue: "#d6acff", BrightMagenta: "#ff92df", BrightCyan: "#a4ffff", BrightWhite: "#ffffff", }, { Name: "Nord", IsBuiltin: true, Foreground: "#d8dee9", Background: "#2e3440", Cursor: "#d8dee9", Black: "#3b4252", Red: "#bf616a", Green: "#a3be8c", Yellow: "#ebcb8b", Blue: "#81a1c1", Magenta: "#b48ead", Cyan: "#88c0d0", White: "#e5e9f0", BrightBlack: "#4c566a", BrightRed: "#bf616a", BrightGreen: "#a3be8c", BrightYellow: "#ebcb8b", BrightBlue: "#81a1c1", BrightMagenta: "#b48ead", BrightCyan: "#8fbcbb", BrightWhite: "#eceff4", }, { Name: "Monokai", IsBuiltin: true, Foreground: "#f8f8f2", Background: "#272822", Cursor: "#f8f8f0", Black: "#272822", Red: "#f92672", Green: "#a6e22e", Yellow: "#f4bf75", Blue: "#66d9ef", Magenta: "#ae81ff", Cyan: "#a1efe4", White: "#f8f8f2", BrightBlack: "#75715e", BrightRed: "#f92672", BrightGreen: "#a6e22e", BrightYellow: "#f4bf75", BrightBlue: "#66d9ef", BrightMagenta: "#ae81ff", BrightCyan: "#a1efe4", BrightWhite: "#f9f8f5", }, { Name: "One Dark", IsBuiltin: true, Foreground: "#abb2bf", Background: "#282c34", Cursor: "#528bff", Black: "#282c34", Red: "#e06c75", Green: "#98c379", Yellow: "#e5c07b", Blue: "#61afef", Magenta: "#c678dd", Cyan: "#56b6c2", White: "#abb2bf", BrightBlack: "#545862", BrightRed: "#e06c75", BrightGreen: "#98c379", BrightYellow: "#e5c07b", BrightBlue: "#61afef", BrightMagenta: "#c678dd", BrightCyan: "#56b6c2", BrightWhite: "#c8ccd4", }, { Name: "Solarized Dark", IsBuiltin: true, Foreground: "#839496", Background: "#002b36", Cursor: "#839496", Black: "#073642", Red: "#dc322f", Green: "#859900", Yellow: "#b58900", Blue: "#268bd2", Magenta: "#d33682", Cyan: "#2aa198", White: "#eee8d5", BrightBlack: "#002b36", BrightRed: "#cb4b16", BrightGreen: "#586e75", BrightYellow: "#657b83", BrightBlue: "#839496", BrightMagenta: "#6c71c4", BrightCyan: "#93a1a1", BrightWhite: "#fdf6e3", }, { Name: "Gruvbox Dark", IsBuiltin: true, Foreground: "#ebdbb2", Background: "#282828", Cursor: "#ebdbb2", Black: "#282828", Red: "#cc241d", Green: "#98971a", Yellow: "#d79921", Blue: "#458588", Magenta: "#b16286", Cyan: "#689d6a", White: "#a89984", BrightBlack: "#928374", BrightRed: "#fb4934", BrightGreen: "#b8bb26", BrightYellow: "#fabd2f", BrightBlue: "#83a598", BrightMagenta: "#d3869b", BrightCyan: "#8ec07c", BrightWhite: "#ebdbb2", }, { Name: "MobaXTerm Classic", IsBuiltin: true, Foreground: "#ececec", Background: "#242424", Cursor: "#b4b4c0", Black: "#000000", Red: "#aa4244", Green: "#7e8d53", Yellow: "#e4b46d", Blue: "#6e9aba", Magenta: "#9e5085", Cyan: "#80d5cf", White: "#cccccc", BrightBlack: "#808080", BrightRed: "#cc7b7d", BrightGreen: "#a5b17c", BrightYellow: "#ecc995", BrightBlue: "#96b6cd", BrightMagenta: "#c083ac", BrightCyan: "#a9e2de", BrightWhite: "#cccccc", }, } ``` - [ ] **Step 2: Write test — seed built-in themes and list them** ```go // internal/theme/service_test.go package theme import ( "path/filepath" "testing" "github.com/vstockwell/wraith/internal/db" ) func setupTestDB(t *testing.T) *ThemeService { t.Helper() d, err := db.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } if err := db.Migrate(d); err != nil { t.Fatal(err) } t.Cleanup(func() { d.Close() }) return NewThemeService(d) } func TestSeedBuiltins(t *testing.T) { svc := setupTestDB(t) if err := svc.SeedBuiltins(); err != nil { t.Fatalf("SeedBuiltins() error: %v", err) } themes, err := svc.List() if err != nil { t.Fatalf("List() error: %v", err) } if len(themes) != len(BuiltinThemes) { t.Errorf("len(themes) = %d, want %d", len(themes), len(BuiltinThemes)) } } func TestSeedBuiltinsIdempotent(t *testing.T) { svc := setupTestDB(t) svc.SeedBuiltins() svc.SeedBuiltins() // Second call should not duplicate themes, _ := svc.List() if len(themes) != len(BuiltinThemes) { t.Errorf("len(themes) = %d after double seed, want %d", len(themes), len(BuiltinThemes)) } } func TestGetByName(t *testing.T) { svc := setupTestDB(t) svc.SeedBuiltins() theme, err := svc.GetByName("Dracula") if err != nil { t.Fatalf("GetByName() error: %v", err) } if theme.Background != "#282a36" { t.Errorf("Background = %q, want %q", theme.Background, "#282a36") } } ``` - [ ] **Step 3: Implement theme service** ```go // internal/theme/service.go package theme import ( "database/sql" "fmt" ) type ThemeService struct { db *sql.DB } func NewThemeService(db *sql.DB) *ThemeService { return &ThemeService{db: db} } func (s *ThemeService) SeedBuiltins() error { for _, t := range BuiltinThemes { _, err := s.db.Exec( `INSERT OR IGNORE INTO themes (name, foreground, background, cursor, black, red, green, yellow, blue, magenta, cyan, white, bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white, selection_bg, selection_fg, is_builtin) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1)`, t.Name, t.Foreground, t.Background, t.Cursor, t.Black, t.Red, t.Green, t.Yellow, t.Blue, t.Magenta, t.Cyan, t.White, t.BrightBlack, t.BrightRed, t.BrightGreen, t.BrightYellow, t.BrightBlue, t.BrightMagenta, t.BrightCyan, t.BrightWhite, t.SelectionBg, t.SelectionFg, ) if err != nil { return fmt.Errorf("seed theme %s: %w", t.Name, err) } } return nil } func (s *ThemeService) List() ([]Theme, error) { rows, err := s.db.Query( `SELECT id, name, foreground, background, cursor, black, red, green, yellow, blue, magenta, cyan, white, bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white, COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin FROM themes ORDER BY is_builtin DESC, name`) if err != nil { return nil, err } defer rows.Close() var themes []Theme for rows.Next() { var t Theme if err := rows.Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor, &t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White, &t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue, &t.BrightMagenta, &t.BrightCyan, &t.BrightWhite, &t.SelectionBg, &t.SelectionFg, &t.IsBuiltin); err != nil { return nil, err } themes = append(themes, t) } return themes, nil } func (s *ThemeService) GetByName(name string) (*Theme, error) { var t Theme err := s.db.QueryRow( `SELECT id, name, foreground, background, cursor, black, red, green, yellow, blue, magenta, cyan, white, bright_black, bright_red, bright_green, bright_yellow, bright_blue, bright_magenta, bright_cyan, bright_white, COALESCE(selection_bg,''), COALESCE(selection_fg,''), is_builtin FROM themes WHERE name = ?`, name, ).Scan(&t.ID, &t.Name, &t.Foreground, &t.Background, &t.Cursor, &t.Black, &t.Red, &t.Green, &t.Yellow, &t.Blue, &t.Magenta, &t.Cyan, &t.White, &t.BrightBlack, &t.BrightRed, &t.BrightGreen, &t.BrightYellow, &t.BrightBlue, &t.BrightMagenta, &t.BrightCyan, &t.BrightWhite, &t.SelectionBg, &t.SelectionFg, &t.IsBuiltin) if err != nil { return nil, fmt.Errorf("get theme %s: %w", name, err) } return &t, nil } ``` - [ ] **Step 4: Run theme tests** ```bash go test ./internal/theme/ -v ``` Expected: 3 PASS - [ ] **Step 5: Commit** ```bash git add internal/theme/ git commit -m "feat: theme service with 7 built-in terminal color schemes" ``` --- ## Task 8: Plugin Interfaces + Session Manager Skeleton **Files:** - Create: `internal/plugin/interfaces.go`, `internal/plugin/registry.go` - Create: `internal/session/session.go`, `internal/session/manager.go`, `internal/session/manager_test.go` - [ ] **Step 1: Define plugin interfaces** ```go // internal/plugin/interfaces.go package plugin // ProtocolHandler defines how a protocol plugin connects and manages sessions. type ProtocolHandler interface { Name() string Connect(config map[string]interface{}) (Session, error) Disconnect(sessionID string) error } // Session represents an active protocol session. type Session interface { ID() string Protocol() string Write(data []byte) error Close() error } // Importer parses configuration files from other tools. type Importer interface { Name() string FileExtensions() []string Parse(data []byte) (*ImportResult, error) } // ImportResult holds parsed data from an imported config file. type ImportResult struct { Groups []ImportGroup `json:"groups"` Connections []ImportConnection `json:"connections"` HostKeys []ImportHostKey `json:"hostKeys"` Theme *ImportTheme `json:"theme,omitempty"` } type ImportGroup struct { Name string `json:"name"` ParentName string `json:"parentName,omitempty"` } type ImportConnection struct { Name string `json:"name"` Hostname string `json:"hostname"` Port int `json:"port"` Protocol string `json:"protocol"` Username string `json:"username"` GroupName string `json:"groupName"` Notes string `json:"notes"` } type ImportHostKey struct { Hostname string `json:"hostname"` Port int `json:"port"` KeyType string `json:"keyType"` Fingerprint string `json:"fingerprint"` } type ImportTheme struct { Name string `json:"name"` Foreground string `json:"foreground"` Background string `json:"background"` Cursor string `json:"cursor"` Colors [16]string `json:"colors"` // ANSI 0-15 } ``` - [ ] **Step 2: Define plugin registry** ```go // internal/plugin/registry.go package plugin import "fmt" type Registry struct { protocols map[string]ProtocolHandler importers map[string]Importer } func NewRegistry() *Registry { return &Registry{ protocols: make(map[string]ProtocolHandler), importers: make(map[string]Importer), } } func (r *Registry) RegisterProtocol(handler ProtocolHandler) { r.protocols[handler.Name()] = handler } func (r *Registry) RegisterImporter(imp Importer) { r.importers[imp.Name()] = imp } func (r *Registry) GetProtocol(name string) (ProtocolHandler, error) { h, ok := r.protocols[name] if !ok { return nil, fmt.Errorf("protocol handler %q not registered", name) } return h, nil } func (r *Registry) GetImporter(name string) (Importer, error) { imp, ok := r.importers[name] if !ok { return nil, fmt.Errorf("importer %q not registered", name) } return imp, nil } func (r *Registry) ListProtocols() []string { names := make([]string, 0, len(r.protocols)) for name := range r.protocols { names = append(names, name) } return names } ``` - [ ] **Step 3: Define session types (data only — no behavior to test)** ```go // internal/session/session.go package session import "time" type SessionState string const ( StateConnecting SessionState = "connecting" StateConnected SessionState = "connected" StateDisconnected SessionState = "disconnected" StateDetached SessionState = "detached" ) type SessionInfo struct { ID string `json:"id"` ConnectionID int64 `json:"connectionId"` Protocol string `json:"protocol"` State SessionState `json:"state"` WindowID string `json:"windowId"` TabPosition int `json:"tabPosition"` ConnectedAt time.Time `json:"connectedAt"` } ``` ```go // internal/session/manager.go package session import ( "fmt" "sync" "github.com/google/uuid" ) const MaxSessions = 32 type Manager struct { mu sync.RWMutex sessions map[string]*SessionInfo } func NewManager() *Manager { return &Manager{ sessions: make(map[string]*SessionInfo), } } func (m *Manager) Create(connectionID int64, protocol string) (*SessionInfo, error) { m.mu.Lock() defer m.mu.Unlock() if len(m.sessions) >= MaxSessions { return nil, fmt.Errorf("maximum sessions (%d) reached", MaxSessions) } s := &SessionInfo{ ID: uuid.NewString(), ConnectionID: connectionID, Protocol: protocol, State: StateConnecting, TabPosition: len(m.sessions), } m.sessions[s.ID] = s return s, nil } func (m *Manager) Get(id string) (*SessionInfo, bool) { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.sessions[id] return s, ok } func (m *Manager) List() []*SessionInfo { m.mu.RLock() defer m.mu.RUnlock() list := make([]*SessionInfo, 0, len(m.sessions)) for _, s := range m.sessions { list = append(list, s) } return list } func (m *Manager) SetState(id string, state SessionState) error { m.mu.Lock() defer m.mu.Unlock() s, ok := m.sessions[id] if !ok { return fmt.Errorf("session %s not found", id) } s.State = state return nil } func (m *Manager) Detach(id string) error { return m.SetState(id, StateDetached) } func (m *Manager) Reattach(id, windowID string) error { m.mu.Lock() defer m.mu.Unlock() s, ok := m.sessions[id] if !ok { return fmt.Errorf("session %s not found", id) } s.State = StateConnected s.WindowID = windowID return nil } func (m *Manager) Remove(id string) { m.mu.Lock() defer m.mu.Unlock() delete(m.sessions, id) } func (m *Manager) Count() int { m.mu.RLock() defer m.mu.RUnlock() return len(m.sessions) } ``` - [ ] **Step 4: Write session manager tests (TDD — tests before manager implementation)** Add `github.com/google/uuid` before running tests: ```bash go get github.com/google/uuid ``` ```go // internal/session/manager_test.go package session import "testing" func TestCreateSession(t *testing.T) { m := NewManager() s, err := m.Create(1, "ssh") if err != nil { t.Fatalf("Create() error: %v", err) } if s.ID == "" { t.Error("session ID should not be empty") } if s.State != StateConnecting { t.Errorf("State = %q, want %q", s.State, StateConnecting) } } func TestMaxSessions(t *testing.T) { m := NewManager() for i := 0; i < MaxSessions; i++ { _, err := m.Create(int64(i), "ssh") if err != nil { t.Fatalf("Create() error at %d: %v", i, err) } } _, err := m.Create(999, "ssh") if err == nil { t.Error("Create() should fail at max sessions") } } func TestDetachReattach(t *testing.T) { m := NewManager() s, _ := m.Create(1, "ssh") m.SetState(s.ID, StateConnected) if err := m.Detach(s.ID); err != nil { t.Fatalf("Detach() error: %v", err) } got, _ := m.Get(s.ID) if got.State != StateDetached { t.Errorf("State = %q, want %q", got.State, StateDetached) } if err := m.Reattach(s.ID, "window-1"); err != nil { t.Fatalf("Reattach() error: %v", err) } got, _ = m.Get(s.ID) if got.State != StateConnected { t.Errorf("State = %q, want %q", got.State, StateConnected) } } func TestRemoveSession(t *testing.T) { m := NewManager() s, _ := m.Create(1, "ssh") m.Remove(s.ID) if m.Count() != 0 { t.Error("session should have been removed") } } ``` - [ ] **Step 5: Run session tests to verify they FAIL (manager not implemented yet)** ```bash go test ./internal/session/ -v ``` Expected: FAIL — `NewManager` not defined - [ ] **Step 5b: Implement session manager (manager.go — the code from Step 3's second block)** Move the `Manager` struct and methods into `manager.go` now that tests exist. - [ ] **Step 5c: Run session tests to verify they PASS** ```bash go test ./internal/session/ -v ``` Expected: 4 PASS - [ ] **Step 6: Commit** ```bash git add internal/plugin/ internal/session/ git commit -m "feat: plugin interfaces + window-agnostic session manager with detach/reattach" ``` --- ## Task 9: Wire Services into Wails App **Files:** - Modify: `main.go` - [ ] **Step 1: Update `main.go` to initialize DB, vault, and register all services** Wire up SQLite, vault unlock flow, and register ConnectionService, SettingsService, ThemeService, and SessionManager as Wails services. The vault unlock happens via a frontend prompt (implemented in Task 10). Create a `WraithApp` struct that holds all services and exposes methods the frontend needs: - `Unlock(password string) error` - `IsFirstRun() bool` - `CreateVault(password string) error` Register it as a Wails service alongside ConnectionService, ThemeService, and SettingsService. - [ ] **Step 2: Verify compilation** ```bash go build -o bin/wraith.exe . ``` - [ ] **Step 3: Commit** ```bash git add main.go git commit -m "feat: wire all services into Wails app entry point" ``` --- ## Task 10: Master Password Unlock UI **Files:** - Create: `frontend/src/layouts/UnlockLayout.vue` - Create: `frontend/src/stores/app.store.ts` - Create: `frontend/src/composables/useVault.ts` - Modify: `frontend/src/App.vue` - [ ] **Step 1: Create app store with unlock state** ```ts // frontend/src/stores/app.store.ts import { defineStore } from "pinia"; import { ref } from "vue"; export const useAppStore = defineStore("app", () => { const unlocked = ref(false); const isFirstRun = ref(false); function setUnlocked(val: boolean) { unlocked.value = val; } function setFirstRun(val: boolean) { isFirstRun.value = val; } return { unlocked, isFirstRun, setUnlocked, setFirstRun }; }); ``` - [ ] **Step 2: Create unlock layout with master password form** Dark-themed centered card with the Wraith logo, master password input, unlock button. First-run mode shows "Create Master Password" with confirm field. - [ ] **Step 3: Update `App.vue` to gate on unlock state** ```vue ``` Note: `MainLayout` is created in Task 11. This placeholder will be replaced there. - [ ] **Step 4: Generate Wails bindings and verify the unlock flow renders** ```bash wails3 generate bindings cd frontend && npm run build && cd .. go run . ``` Note: `wails3 generate bindings` scans registered Go services and generates TypeScript bindings into `frontend/bindings/`. Frontend code imports from these bindings to call Go methods. Re-run this command whenever Go service methods change. - [ ] **Step 5: Commit** ```bash git add frontend/src/ git commit -m "feat: master password unlock UI with first-run vault creation" ``` --- ## Task 11: Main Layout — Sidebar + Tab Container + Status Bar **Files:** - Create: `frontend/src/layouts/MainLayout.vue` - Create: `frontend/src/components/sidebar/ConnectionTree.vue` - Create: `frontend/src/components/sidebar/SidebarToggle.vue` - Create: `frontend/src/components/session/TabBar.vue` - Create: `frontend/src/components/session/SessionContainer.vue` - Create: `frontend/src/components/common/StatusBar.vue` - Create: `frontend/src/stores/connection.store.ts` - Create: `frontend/src/stores/session.store.ts` - Create: `frontend/src/composables/useConnections.ts` - [ ] **Step 1: Create connection store** Pinia store wrapping Wails bindings for ConnectionService: `groups`, `connections`, `searchQuery`, `activeTag`, computed filtered lists. - [ ] **Step 2: Create session store** Pinia store wrapping SessionManager bindings: `sessions`, `activeSessionId`, `tabOrder`. - [ ] **Step 3: Create `MainLayout.vue`** Three-panel layout: resizable sidebar (240px default), tab bar + session area, status bar. Uses CSS Grid. - [ ] **Step 4: Create `ConnectionTree.vue`** Renders hierarchical group tree with connection entries. Search bar at top, tag filter pills, recent connections section. Uses Naive UI `NTree` component. Green dots for SSH, blue for RDP. - [ ] **Step 5: Create `SidebarToggle.vue`** Toggle buttons: Connections | SFTP. Switches the sidebar content area. - [ ] **Step 6: Create `TabBar.vue`** Horizontal tab bar with color-coded dots, close button, "+" button. Active tab highlighted with blue bottom border and 0.5s CSS transition. - [ ] **Step 7: Create `SessionContainer.vue`** Holds active session placeholders (empty state for Phase 1 — "Connect to a host to start a session"). Uses `v-show` for tab switching. - [ ] **Step 8: Create `StatusBar.vue`** Bottom bar with connection info (left) and app info (right): theme name, encoding, terminal dimensions placeholder. - [ ] **Step 9: Build and verify layout renders** ```bash cd frontend && npm run build && cd .. go run . ``` - [ ] **Step 10: Commit** ```bash git add frontend/src/ git commit -m "feat: main layout — sidebar connection tree, tab bar, status bar" ``` --- ## Task 12: Multi-Window Spike (Plan A Validation) **Files:** - Create: `spike/multiwindow/main.go` (temporary) - [ ] **Step 1: Write a minimal two-window Wails v3 test** Create a spike app that: 1. Opens a main window 2. Has a button that opens a second window via `app.NewWebviewWindowWithOptions()` 3. Both windows can call the same Go service method 4. Closing the second window doesn't crash the app - [ ] **Step 2: Run the spike on Windows (or via cross-compile check)** ```bash cd spike/multiwindow && go run . ``` Document results: does `NewWebviewWindow` work? Can both windows access the same service? Any crashes? - [ ] **Step 3: Record findings** Create `docs/spikes/multi-window-results.md` with: - Plan A status: WORKS / PARTIAL / FAILED - Any issues found - Fallback recommendation if needed - [ ] **Step 4: Clean up spike** ```bash rm -rf spike/ ``` - [ ] **Step 5: Commit findings** ```bash git add docs/spikes/ git commit -m "spike: Wails v3 multi-window validation — Plan A results" ``` --- ## Task 13: RDP Frame Transport Spike **Files:** - Create: `spike/frametransport/main.go` (temporary) - [ ] **Step 1: Write a spike that benchmarks frame delivery** Create a Go app that: 1. Generates a 1920x1080 RGBA test frame (solid color + timestamp) 2. Serves it via local HTTP endpoint (`/frame`) 3. Also exposes it via a Wails binding (base64-encoded PNG) 4. Frontend renders both approaches on a `` and measures FPS - [ ] **Step 2: Run benchmark** Target: which approach sustains 30fps at 1080p? - [ ] **Step 3: Record findings** Create `docs/spikes/rdp-frame-transport-results.md` with: - HTTP approach: measured FPS, latency, CPU usage - Base64 approach: measured FPS, latency, CPU usage - Recommendation for Phase 3 - [ ] **Step 4: Clean up spike** ```bash rm -rf spike/ ``` - [ ] **Step 5: Commit findings** ```bash git add docs/spikes/ git commit -m "spike: RDP frame transport benchmark — HTTP vs base64 results" ``` --- ## Task 14: README.md **Files:** - Create: `README.md` - [ ] **Step 1: Write comprehensive README** Cover: - Project overview (what Wraith is, screenshot/logo) - Features list - Tech stack - Prerequisites (Go 1.22+, Node 20+, Wails v3 CLI) - Build instructions (dev mode, production build) - Project structure walkthrough - Architecture overview (Go services → Wails bindings → Vue 3 frontend) - Plugin development guide (implement ProtocolHandler or Importer interface) - Contributing guidelines - License (MIT) - [ ] **Step 2: Commit** ```bash git add README.md git commit -m "docs: comprehensive README with architecture, build, and plugin guide" ``` --- ## Task 15: License Audit + Final Verification - [ ] **Step 1: Audit all Go dependencies** ```bash go list -m all ``` Verify each is MIT, BSD, Apache 2.0, or ISC. No GPL/AGPL. - [ ] **Step 2: Audit all npm dependencies** ```bash cd frontend && npx license-checker --summary && cd .. ``` - [ ] **Step 3: Run all Go tests** ```bash go test ./... -v ``` Expected: All pass (vault, db, connections, search, settings, theme, session) - [ ] **Step 4: Build production binary** ```bash cd frontend && npm run build && cd .. go build -o bin/wraith.exe . ``` - [ ] **Step 5: Commit any fixes** ```bash git add -A git commit -m "chore: Phase 1 complete — license audit passed, all tests green" ``` --- ## Phase 1 Completion Checklist - [ ] Wails v3 scaffold compiles and runs - [ ] SQLite with WAL mode + full schema - [ ] Vault service: Argon2id + AES-256-GCM (7 passing tests) - [ ] Connection + Group CRUD with JSON tags/options - [ ] Search by name/hostname/tag with `json_each` - [ ] Settings service (key-value with upsert) - [ ] Theme service with 7 built-in themes - [ ] Plugin interfaces defined (ProtocolHandler, Importer) - [ ] Session manager (window-agnostic, detach/reattach, 32 session cap) - [ ] Master password unlock UI - [ ] Main layout: sidebar + tab bar + status bar - [ ] Multi-window spike completed with documented results - [ ] RDP frame transport spike completed with documented results - [ ] README.md with architecture and plugin guide - [ ] All dependencies MIT/BSD/Apache 2.0 compatible - [ ] All Go tests passing