Go + Wails v3 + Vue 3 + SQLite + FreeRDP3 (purego) 183 tests, 76 source files, 9,910 lines of code Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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/sqlitedependency
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
scanConnectionshelper toservice.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.goto 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) errorIsFirstRun() boolCreateVault(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.vueto 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:
- Opens a main window
- Has a button that opens a second window via
app.NewWebviewWindowWithOptions() - Both windows can call the same Go service method
- 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:
- Generates a 1920x1080 RGBA test frame (solid color + timestamp)
- Serves it via local HTTP endpoint (
/frame) - Also exposes it via a Wails binding (base64-encoded PNG)
- 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