safevibe
Version:
Safevibe CLI - Simple personal secret vault for AI developers and amateur vibe coders
233 lines (232 loc) • 8.67 kB
JavaScript
import { createHash } from "node:crypto";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";
/**
* HTTP client that mimics tRPC interface
*/
class HttpTrpcClient {
baseUrl;
getHeaders;
constructor(baseUrl, getHeaders) {
this.baseUrl = baseUrl;
this.getHeaders = getHeaders;
}
secret = {
save: {
mutate: async (params) => {
const endpoint = 'cli.saveSecret';
const response = await fetch(`${this.baseUrl}/api/trpc/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...this.getHeaders(),
},
body: JSON.stringify({ json: params }),
});
if (!response.ok) {
throw new Error(`Failed to save secret: ${response.statusText}`);
}
const data = await response.json();
return data.result?.data?.json || { version: 1 };
},
},
get: {
query: async (params) => {
const endpoint = 'cli.getSecret';
const url = new URL(`${this.baseUrl}/api/trpc/${endpoint}`);
url.searchParams.set("input", JSON.stringify({ json: params }));
const response = await fetch(url.toString(), {
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Failed to get secret: ${response.statusText}`);
}
const data = await response.json();
return data.result?.data?.json || { ciphertext: "", version: 1 };
},
},
list: {
query: async () => {
const endpoint = 'cli.listSecrets';
const response = await fetch(`${this.baseUrl}/api/trpc/${endpoint}`, {
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`Failed to list secrets: ${response.statusText}`);
}
const data = await response.json();
return data.result?.data?.json || [];
},
},
rotate: {
mutate: async (params) => {
const endpoint = 'cli.rotateSecret';
const response = await fetch(`${this.baseUrl}/api/trpc/${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...this.getHeaders(),
},
body: JSON.stringify({ json: params }),
});
if (!response.ok) {
throw new Error(`Failed to rotate secret: ${response.statusText}`);
}
const data = await response.json();
return data.result?.data?.json || { version: 1 };
},
},
};
}
/**
* Safevibe Client for MCP Server
*
* Handles:
* - HTTP communication with backend
* - Local X25519 encryption/decryption
* - Session management
* - Configuration persistence
*
* Simplified to work without projects - everything is user-scoped
*/
export class SafevibeClient {
trpc;
config;
configPath;
constructor() {
// Default configuration - use production backend
this.config = {
backendUrl: process.env.SAFEVIBE_BACKEND_URL || "https://safevibe.vercel.app",
};
// Configuration file path
this.configPath = join(homedir(), ".safevibe", "mcp-config.json");
// Initialize HTTP client that mimics tRPC
this.trpc = new HttpTrpcClient(this.config.backendUrl, () => {
const headers = {};
if (this.config.sessionToken) {
headers.Authorization = `Bearer ${this.config.sessionToken}`;
}
return headers;
});
}
/**
* Initialize the client
* Load configuration, ensure authentication, setup encryption keys
*/
async initialize() {
await this.loadConfig();
await this.ensureAuthenticated();
await this.ensureKeys();
}
/**
* Cleanup resources
*/
async cleanup() {
// Nothing to clean up for now
}
/**
* Load configuration from file system
*/
async loadConfig() {
try {
const configData = await readFile(this.configPath, "utf-8");
const fileConfig = JSON.parse(configData);
this.config = { ...this.config, ...fileConfig };
}
catch (error) {
// Config file doesn't exist or invalid, use defaults
console.error("📝 No MCP config found, using defaults");
}
}
/**
* Save configuration to file system
*/
async saveConfig() {
try {
await mkdir(join(homedir(), ".safevibe"), { recursive: true });
await writeFile(this.configPath, JSON.stringify(this.config, null, 2));
}
catch (error) {
console.error("❌ Failed to save MCP config:", error);
}
}
/**
* Ensure user is authenticated
* Try to load session from CLI config, require proper authentication
*/
async ensureAuthenticated() {
// Try to load session from CLI config
try {
const cliConfigPath = join(homedir(), ".safevibe", "config.json");
const cliConfigData = await readFile(cliConfigPath, "utf-8");
const cliConfig = JSON.parse(cliConfigData);
if (cliConfig.sessionToken && cliConfig.userId) {
console.error("✅ Found CLI authentication");
this.config.sessionToken = cliConfig.sessionToken;
this.config.userId = cliConfig.userId;
this.config.backendUrl = cliConfig.backendUrl || this.config.backendUrl;
await this.saveConfig();
return;
}
}
catch (error) {
// CLI config doesn't exist or invalid
}
throw new Error("No valid authentication found. Please run 'safevibe init' to authenticate first.");
}
/**
* Ensure cryptographic keys are generated
* For demo purposes, we'll use a simple symmetric encryption
* In production, this would use X25519 with proper key derivation
*/
async ensureKeys() {
if (!this.config.privateKey || !this.config.publicKey) {
console.error("🔑 Generating new encryption keys...");
// For demo: simple key derivation from a password
// In production: use X25519 key generation
const seed = process.env.SAFEVIBE_ENCRYPTION_KEY || "demo-key-safevibe-2024";
const hash = createHash("sha256").update(seed).digest("hex");
this.config.privateKey = hash.substring(0, 32);
this.config.publicKey = hash.substring(32, 64);
await this.saveConfig();
console.error("✅ Encryption keys generated and saved");
}
}
/**
* Encrypt a secret value locally
* For demo: simple XOR encryption
* In production: use age encryption or similar with X25519
*/
async encryptSecret(plaintext) {
if (!this.config.privateKey) {
throw new Error("No encryption key available");
}
// Simple XOR encryption for demo
const key = Buffer.from(this.config.privateKey, "hex");
const data = Buffer.from(plaintext, "utf-8");
const encrypted = Buffer.alloc(data.length);
for (let i = 0; i < data.length; i++) {
encrypted[i] = data[i] ^ key[i % key.length];
}
return encrypted.toString("base64");
}
/**
* Decrypt a secret value locally
* For demo: simple XOR decryption
* In production: use age decryption or similar with X25519
*/
async decryptSecret(ciphertext) {
if (!this.config.privateKey) {
throw new Error("No encryption key available");
}
// Simple XOR decryption for demo (same as encryption)
const key = Buffer.from(this.config.privateKey, "hex");
const data = Buffer.from(ciphertext, "base64");
const decrypted = Buffer.alloc(data.length);
for (let i = 0; i < data.length; i++) {
decrypted[i] = data[i] ^ key[i % key.length];
}
return decrypted.toString("utf-8");
}
}