Initial commit: Sencho V1 complete with Auth and Dockerization

This commit is contained in:
unknown
2026-02-20 18:39:32 -05:00
commit 293f9cef26
51 changed files with 11547 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
import { spawn } from 'child_process';
import path from 'path';
import WebSocket from 'ws';
export class ComposeService {
private baseDir: string;
constructor() {
this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose');
}
runCommand(filename: string, action: 'up' | 'down', ws?: WebSocket) {
const filePath = path.join(this.baseDir, filename);
const args = action === 'up' ? ['compose', '-f', filePath, 'up', '-d'] : ['compose', '-f', filePath, 'down'];
const child = spawn('docker', args, { shell: true });
if (ws) {
child.stdout.on('data', (data: Buffer) => {
ws.send(data.toString());
});
child.stderr.on('data', (data: Buffer) => {
ws.send(data.toString());
});
child.on('close', (code: number | null) => {
ws.send(`Command exited with code ${code}\n`);
});
child.on('error', (error: Error) => {
ws.send(`Error: ${error.message}\n`);
});
}
}
// Update command: pull images first, then recreate containers
async updateStack(filename: string, ws?: WebSocket): Promise<void> {
const filePath = path.join(this.baseDir, filename);
const sendOutput = (data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
};
// Step 1: Pull images
sendOutput('=== Pulling latest images ===\n');
await new Promise<void>((resolve, reject) => {
const pullProcess = spawn('docker', ['compose', '-f', filePath, 'pull'], { shell: true });
pullProcess.stdout.on('data', (data: Buffer) => {
sendOutput(data.toString());
});
pullProcess.stderr.on('data', (data: Buffer) => {
sendOutput(data.toString());
});
pullProcess.on('close', (code: number | null) => {
if (code === 0) {
sendOutput('=== Images pulled successfully ===\n');
resolve();
} else {
sendOutput(`=== Pull failed with code ${code} ===\n`);
reject(new Error(`Pull failed with code ${code}`));
}
});
pullProcess.on('error', (error: Error) => {
sendOutput(`Pull error: ${error.message}\n`);
reject(error);
});
});
// Step 2: Recreate containers with new images
sendOutput('=== Recreating containers ===\n');
await new Promise<void>((resolve, reject) => {
const upProcess = spawn('docker', ['compose', '-f', filePath, 'up', '-d'], { shell: true });
upProcess.stdout.on('data', (data: Buffer) => {
sendOutput(data.toString());
});
upProcess.stderr.on('data', (data: Buffer) => {
sendOutput(data.toString());
});
upProcess.on('close', (code: number | null) => {
if (code === 0) {
sendOutput('=== Stack updated successfully ===\n');
resolve();
} else {
sendOutput(`=== Update failed with code ${code} ===\n`);
reject(new Error(`Up failed with code ${code}`));
}
});
upProcess.on('error', (error: Error) => {
sendOutput(`Update error: ${error.message}\n`);
reject(error);
});
});
}
}
+61
View File
@@ -0,0 +1,61 @@
import { promises as fs } from 'fs';
import path from 'path';
import bcrypt from 'bcrypt';
interface AuthConfig {
username: string;
passwordHash: string;
}
export class ConfigService {
private dataDir: string;
private configPath: string;
constructor() {
this.dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
this.configPath = path.join(this.dataDir, 'sencho.json');
}
private async ensureDataDir(): Promise<void> {
try {
await fs.mkdir(this.dataDir, { recursive: true });
} catch {
// Directory already exists
}
}
async needsSetup(): Promise<boolean> {
try {
const config = await this.readConfig();
return !config || !config.username || !config.passwordHash;
} catch {
return true;
}
}
async readConfig(): Promise<AuthConfig | null> {
try {
const data = await fs.readFile(this.configPath, 'utf-8');
return JSON.parse(data);
} catch {
return null;
}
}
async saveConfig(username: string, password: string): Promise<void> {
await this.ensureDataDir();
const saltRounds = 10;
const passwordHash = await bcrypt.hash(password, saltRounds);
const config: AuthConfig = { username, passwordHash };
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
}
async validateCredentials(username: string, password: string): Promise<boolean> {
const config = await this.readConfig();
if (!config) return false;
if (username !== config.username) return false;
return await bcrypt.compare(password, config.passwordHash);
}
}
+149
View File
@@ -0,0 +1,149 @@
import Docker from 'dockerode';
import WebSocket from 'ws';
import { Duplex } from 'stream';
class DockerController {
private static instance: DockerController;
private docker: Docker;
private execStream: Duplex | null = null;
private currentExec: Docker.Exec | null = null;
private constructor() {
this.docker = new Docker({ socketPath: '/var/run/docker.sock' });
}
public static getInstance(): DockerController {
if (!DockerController.instance) {
DockerController.instance = new DockerController();
}
return DockerController.instance;
}
public async getRunningContainers() {
const containers = await this.docker.listContainers({ all: false });
return containers;
}
public async getAllContainers() {
const containers = await this.docker.listContainers({ all: true });
return containers;
}
public async getContainersByStack(stackName: string) {
const containers = await this.docker.listContainers({ all: true });
// Normalize the stack name: remove all non-alphanumeric characters and lowercase
// Docker Compose strips hyphens and underscores from project names
const normalizedStackName = stackName.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
return containers.filter(container => {
if (!container.Labels || !container.Labels['com.docker.compose.project']) {
return false;
}
// Normalize the Docker label for comparison
const projectLabel = container.Labels['com.docker.compose.project'].replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
return projectLabel === normalizedStackName;
});
}
public async startContainer(containerId: string) {
const container = this.docker.getContainer(containerId);
await container.start();
}
public async stopContainer(containerId: string) {
const container = this.docker.getContainer(containerId);
await container.stop();
}
public async restartContainer(containerId: string) {
const container = this.docker.getContainer(containerId);
await container.restart();
}
public async streamStats(containerId: string, ws: WebSocket) {
const container = this.docker.getContainer(containerId);
const stats = await container.stats({ stream: true });
stats.on('data', (chunk: Buffer) => {
ws.send(chunk.toString());
});
stats.on('error', (err: Error) => {
ws.send(JSON.stringify({ error: err.message }));
});
stats.on('end', () => {
ws.send(JSON.stringify({ end: true }));
});
}
public async execContainer(containerId: string, ws: WebSocket) {
try {
const container = this.docker.getContainer(containerId);
// Try bash first, fall back to sh
let exec: Docker.Exec;
try {
exec = await container.exec({
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: ['/bin/bash'],
});
} catch {
exec = await container.exec({
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: ['/bin/sh'],
});
}
this.currentExec = exec;
const stream = await exec.start({ hijack: true, stdin: true });
this.execStream = stream;
// Handle output from container
stream.on('data', (chunk: Buffer) => {
ws.send(JSON.stringify({ type: 'output', data: chunk.toString() }));
});
stream.on('error', (err: Error) => {
ws.send(JSON.stringify({ type: 'error', message: err.message }));
});
stream.on('end', () => {
ws.send(JSON.stringify({ type: 'exit' }));
this.execStream = null;
this.currentExec = null;
});
ws.send(JSON.stringify({ type: 'connected' }));
} catch (error) {
const err = error as Error;
ws.send(JSON.stringify({ type: 'error', message: err.message }));
}
}
public sendExecInput(data: string) {
if (this.execStream) {
this.execStream.write(data);
}
}
public async resizeExec(cols: number, rows: number) {
if (this.currentExec) {
try {
await this.currentExec.resize({ w: cols, h: rows });
} catch {
// Ignore resize errors
}
}
}
}
export default DockerController;
+116
View File
@@ -0,0 +1,116 @@
import { promises as fs } from 'fs';
import path from 'path';
export class FileSystemService {
private baseDir: string;
constructor() {
this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose');
}
async getStackFiles(): Promise<string[]> {
try {
const files = await fs.readdir(this.baseDir);
return files.filter(file => file.endsWith('.yml') || file.endsWith('.yaml'));
} catch (error) {
console.error('Error reading stack files:', error);
return [];
}
}
async getStackContent(filename: string): Promise<string> {
const filePath = path.join(this.baseDir, filename);
try {
return await fs.readFile(filePath, 'utf-8');
} catch (error) {
console.error('Error reading file:', error);
throw new Error(`Failed to read file: ${filename}`);
}
}
async saveStackContent(filename: string, content: string): Promise<void> {
const filePath = path.join(this.baseDir, filename);
console.log('Saving to path:', filePath);
try {
await fs.writeFile(filePath, content, 'utf-8');
console.log('File written successfully');
} catch (error) {
console.error('Error writing file:', error);
throw new Error(`Failed to save file: ${filename}`);
}
}
async envExists(filename: string): Promise<boolean> {
const envFilename = filename.replace(/\.yml$/, '.env');
const envPath = path.join(this.baseDir, envFilename);
try {
await fs.access(envPath);
return true;
} catch {
return false;
}
}
async getEnvContent(filename: string): Promise<string> {
const envFilename = filename.replace(/\.yml$/, '.env');
const envPath = path.join(this.baseDir, envFilename);
try {
return await fs.readFile(envPath, 'utf-8');
} catch (error) {
console.error('Error reading env file:', error);
throw new Error(`Failed to read env file for: ${filename}`);
}
}
async saveEnvContent(filename: string, content: string): Promise<void> {
const envFilename = filename.replace(/\.yml$/, '.env');
const envPath = path.join(this.baseDir, envFilename);
console.log('Saving env to path:', envPath);
try {
await fs.writeFile(envPath, content, 'utf-8');
console.log('Env file written successfully');
} catch (error) {
console.error('Error writing env file:', error);
throw new Error(`Failed to save env file for: ${filename}`);
}
}
async createStack(filename: string): Promise<void> {
if (!filename.endsWith('.yml')) {
throw new Error('Filename must end with .yml');
}
const filePath = path.join(this.baseDir, filename);
const boilerplate = `version: '3.8'
services:
# Add your services here
`;
try {
await fs.writeFile(filePath, boilerplate, 'utf-8');
console.log('Stack file created successfully:', filename);
} catch (error) {
console.error('Error creating stack file:', error);
throw new Error(`Failed to create stack file: ${filename}`);
}
}
async deleteStack(filename: string): Promise<void> {
const filePath = path.join(this.baseDir, filename);
const envFilename = filename.replace(/\.yml$/, '.env');
const envPath = path.join(this.baseDir, envFilename);
try {
await fs.unlink(filePath);
console.log('Stack file deleted successfully:', filename);
// Try to delete env file if it exists
try {
await fs.unlink(envPath);
console.log('Associated env file deleted:', envFilename);
} catch {
// Env file doesn't exist, ignore
}
} catch (error) {
console.error('Error deleting stack file:', error);
throw new Error(`Failed to delete stack file: ${filename}`);
}
}
}