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