From c64ddac18bcdf66c1f7143c5c2257b82820a4898 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Tue, 17 Mar 2026 06:01:34 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20Phase=201=20implementation=20plan=20?= =?UTF-8?q?=E2=80=94=2015=20tasks=20with=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation plan covering: Wails v3 scaffold, SQLite+WAL, vault encryption, connection/group CRUD, search, themes, settings, plugin interfaces, session manager, master password UI, main layout shell, multi-window spike, RDP frame transport spike, README, and license audit. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-17-wraith-phase1-foundation.md | 2690 +++++++++++++++++ 1 file changed, 2690 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md diff --git a/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md b/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md new file mode 100644 index 0000000..8e98ad2 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md @@ -0,0 +1,2690 @@ +# 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