wraith/docs/superpowers/plans/2026-03-17-wraith-phase1-foundation.md
Vantz Stockwell c64ddac18b docs: Phase 1 implementation plan — 15 tasks with TDD
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) <noreply@anthropic.com>
2026-03-17 06:01:34 -04:00

69 KiB

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

cd /Users/vstockwell/repos/wraith
go mod init github.com/vstockwell/wraith
  • Step 2: Install Wails v3 CLI (if not already installed)
go install github.com/wailsapp/wails/v3/cmd/wails3@latest
  • Step 3: Create .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
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
{
  "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
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
{
  "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
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Wraith</title>
  </head>
  <body class="bg-[#0d1117] text-[#e0e0e0]">
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
  • Step 10: Create frontend/src/assets/css/main.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
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
<template>
  <div class="h-screen flex items-center justify-center">
    <div class="text-center">
      <h1 class="text-3xl font-bold text-[#58a6ff]">WRAITH</h1>
      <p class="text-[#8b949e] mt-2">Desktop shell loading...</p>
    </div>
  </div>
</template>
  • Step 13: Install frontend dependencies and verify build
cd frontend && npm install && npm run build && cd ..
  • Step 14: Install Go dependencies and verify compilation
go mod tidy
go build -o bin/wraith.exe .
  • Step 15: Commit scaffold
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

// 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
go test ./internal/db/ -v

Expected: FAIL — Open not defined

  • Step 3: Implement internal/db/sqlite.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
go get modernc.org/sqlite
go mod tidy
  • Step 5: Run tests to verify they pass
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).

-- 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
// 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
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
go test ./internal/db/ -v

Expected: 4 PASS

  • Step 10: Commit
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

// 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
go test ./internal/vault/ -v

Expected: FAIL — DeriveKey not defined

  • Step 3: Implement key derivation
// 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
go get golang.org/x/crypto
go test ./internal/vault/ -v

Expected: 2 PASS

  • Step 5: Write test — Encrypt/Decrypt round-trip
// 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
// 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
go test ./internal/vault/ -v

Expected: 6 PASS

  • Step 8: Write test — GenerateSalt produces 32 random bytes
// 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
// 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
go test ./internal/vault/ -v

Expected: 7 PASS

  • Step 11: Commit
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

// 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
go test ./internal/settings/ -v
  • Step 3: Implement settings service
// 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
go test ./internal/settings/ -v

Expected: 4 PASS

  • Step 5: Commit
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

// 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
// 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
// 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
go test ./internal/connections/ -v -run "TestCreateGroup|TestCreateSubGroup|TestListGroups|TestDeleteGroup"

Expected: 4 PASS

  • Step 5: Write tests for connection CRUD
// 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
// 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
go test ./internal/connections/ -v

Expected: 8 PASS (4 group + 4 connection)

  • Step 8: Commit
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

// 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
// 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
// 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
go test ./internal/connections/ -v

Expected: 12 PASS

  • Step 5: Commit
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

// 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
// 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
// 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
go test ./internal/theme/ -v

Expected: 3 PASS

  • Step 5: Commit
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

// 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
// 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)
// 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"`
}
// 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:

go get github.com/google/uuid
// 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)
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
go test ./internal/session/ -v

Expected: 4 PASS

  • Step 6: Commit
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
go build -o bin/wraith.exe .
  • Step 3: Commit
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

// 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
<template>
  <UnlockLayout v-if="!appStore.unlocked" />
  <div v-else class="h-screen flex items-center justify-center">
    <p class="text-[#58a6ff]">Vault unlocked  MainLayout coming in Task 11</p>
  </div>
</template>

Note: MainLayout is created in Task 11. This placeholder will be replaced there.

  • Step 4: Generate Wails bindings and verify the unlock flow renders
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
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
cd frontend && npm run build && cd ..
go run .
  • Step 10: Commit
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)
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

rm -rf spike/
  • Step 5: Commit findings
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 <canvas> 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

rm -rf spike/
  • Step 5: Commit findings
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

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
go list -m all

Verify each is MIT, BSD, Apache 2.0, or ISC. No GPL/AGPL.

  • Step 2: Audit all npm dependencies
cd frontend && npx license-checker --summary && cd ..
  • Step 3: Run all Go tests
go test ./... -v

Expected: All pass (vault, db, connections, search, settings, theme, session)

  • Step 4: Build production binary
cd frontend && npm run build && cd ..
go build -o bin/wraith.exe .
  • Step 5: Commit any fixes
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