refactor: enterprise-grade project structure

- Move test files to tests/
- Archive session notes to docs/archive/
- Remove temp/diagnostic files
- Clean src/ to only contain production code
This commit is contained in:
Oussama Douhou
2026-01-10 12:32:54 +01:00
parent b83f253582
commit e617114310
15 changed files with 0 additions and 774 deletions

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env bun
/**
* Diagnostic script to test application.create API call
* Captures exact error message and request/response
*/
import { createDokployClient } from './api/dokploy.js';
console.log('═══════════════════════════════════════');
console.log(' Diagnosing application.create API');
console.log('═══════════════════════════════════════\n');
try {
const client = createDokployClient();
// Use existing project ID from earlier test
const projectId = 'MV2b-c1hIW4-Dww8Xoinj';
const appName = `test-diagnostic-${Date.now()}`;
console.log(`Project ID: ${projectId}`);
console.log(`App Name: ${appName}`);
console.log(`Docker Image: nginx:alpine`);
console.log();
console.log('Making API call...\n');
const application = await client.createApplication(
appName,
projectId,
'nginx:alpine'
);
console.log('✅ Success! Application created:');
console.log(JSON.stringify(application, null, 2));
} catch (error) {
console.error('❌ Failed to create application\n');
console.error('Error details:');
if (error instanceof Error) {
console.error(`Message: ${error.message}`);
console.error(`\nStack trace:`);
console.error(error.stack);
} else {
console.error(error);
}
process.exit(1);
}

View File

@@ -1,351 +0,0 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun';
import { streamSSE } from 'hono/streaming';
import { createDokployClient } from './api/dokploy.js';
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
// Deployment state tracking
interface DeploymentState {
id: string;
name: string;
status: 'initializing' | 'creating_project' | 'creating_application' | 'deploying' | 'completed' | 'failed';
url?: string;
error?: string;
createdAt: Date;
projectId?: string;
applicationId?: string;
progress: number;
currentStep: string;
}
const deployments = new Map<string, DeploymentState>();
// Generate a unique deployment ID
function generateDeploymentId(): string {
return `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Validate stack name
function validateStackName(name: string): { valid: boolean; error?: string } {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'Name is required' };
}
const trimmedName = name.trim().toLowerCase();
if (trimmedName.length < 3 || trimmedName.length > 20) {
return { valid: false, error: 'Name must be between 3 and 20 characters' };
}
if (!/^[a-z0-9-]+$/.test(trimmedName)) {
return { valid: false, error: 'Name can only contain lowercase letters, numbers, and hyphens' };
}
if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) {
return { valid: false, error: 'Name cannot start or end with a hyphen' };
}
const reservedNames = (process.env.RESERVED_NAMES || 'admin,api,www,root,system,test,demo,portal').split(',');
if (reservedNames.includes(trimmedName)) {
return { valid: false, error: `Name "${trimmedName}" is reserved` };
}
return { valid: true };
}
// Main deployment orchestration
async function deployStack(deploymentId: string): Promise<void> {
const deployment = deployments.get(deploymentId);
if (!deployment) {
throw new Error('Deployment not found');
}
try {
const dokployClient = createDokployClient();
const domain = `${deployment.name}.${process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl'}`;
// Step 1: Create Dokploy project
deployment.status = 'creating_project';
deployment.progress = 25;
deployment.currentStep = 'Creating Dokploy project';
deployments.set(deploymentId, { ...deployment });
const projectName = `ai-stack-${deployment.name}`;
let project = await dokployClient.findProjectByName(projectName);
if (!project) {
project = await dokployClient.createProject(
projectName,
`AI Stack for ${deployment.name}`
);
}
deployment.projectId = project.projectId;
deployments.set(deploymentId, { ...deployment });
// Step 2: Create application
deployment.status = 'creating_application';
deployment.progress = 50;
deployment.currentStep = 'Creating application container';
deployments.set(deploymentId, { ...deployment });
const dockerImage = process.env.STACK_IMAGE || 'git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest';
const application = await dokployClient.createApplication(
`opencode-${deployment.name}`,
project.projectId,
dockerImage
);
deployment.applicationId = application.applicationId;
deployments.set(deploymentId, { ...deployment });
// Step 3: Configure domain
deployment.progress = 70;
deployment.currentStep = 'Configuring domain';
deployments.set(deploymentId, { ...deployment });
await dokployClient.createDomain(
domain,
application.applicationId,
true,
8080
);
// Step 4: Deploy application
deployment.status = 'deploying';
deployment.progress = 85;
deployment.currentStep = 'Deploying application';
deployments.set(deploymentId, { ...deployment });
await dokployClient.deployApplication(application.applicationId);
// Mark as completed
deployment.status = 'completed';
deployment.progress = 100;
deployment.currentStep = 'Deployment complete';
deployment.url = `https://${domain}`;
deployments.set(deploymentId, { ...deployment });
} catch (error) {
deployment.status = 'failed';
deployment.error = error instanceof Error ? error.message : 'Unknown error';
deployment.currentStep = 'Deployment failed';
deployments.set(deploymentId, { ...deployment });
throw error;
}
}
const app = new Hono();
app.use('*', logger());
app.use('*', cors());
app.get('/health', (c) => {
return c.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '0.1.0',
service: 'ai-stack-deployer',
activeDeployments: deployments.size
});
});
// Root path now served by static frontend (removed JSON response)
// app.get('/', ...) - see bottom of file for static file serving
// Deploy endpoint
app.post('/api/deploy', async (c) => {
try {
const body = await c.req.json();
const { name } = body;
// Validate name
const validation = validateStackName(name);
if (!validation.valid) {
return c.json({
success: false,
error: validation.error,
code: 'INVALID_NAME'
}, 400);
}
const normalizedName = name.trim().toLowerCase();
// Check if name is already taken
const dokployClient = createDokployClient();
const projectName = `ai-stack-${normalizedName}`;
const existingProject = await dokployClient.findProjectByName(projectName);
if (existingProject) {
return c.json({
success: false,
error: 'Name already taken',
code: 'NAME_EXISTS'
}, 409);
}
// Create deployment
const deploymentId = generateDeploymentId();
const deployment: DeploymentState = {
id: deploymentId,
name: normalizedName,
status: 'initializing',
createdAt: new Date(),
progress: 0,
currentStep: 'Initializing deployment'
};
deployments.set(deploymentId, deployment);
// Start deployment in background
deployStack(deploymentId).catch(err => {
console.error(`Deployment ${deploymentId} failed:`, err);
});
return c.json({
success: true,
deploymentId,
url: `https://${normalizedName}.${process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl'}`,
statusEndpoint: `/api/status/${deploymentId}`
});
} catch (error) {
console.error('Deploy endpoint error:', error);
return c.json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
code: 'INTERNAL_ERROR'
}, 500);
}
});
// Status endpoint with SSE
app.get('/api/status/:deploymentId', (c) => {
const deploymentId = c.req.param('deploymentId');
const deployment = deployments.get(deploymentId);
if (!deployment) {
return c.json({
success: false,
error: 'Deployment not found',
code: 'NOT_FOUND'
}, 404);
}
return streamSSE(c, async (stream) => {
let lastStatus = '';
try {
// Stream updates until deployment completes or fails
while (true) {
const currentDeployment = deployments.get(deploymentId);
if (!currentDeployment) {
await stream.writeSSE({
event: 'error',
data: JSON.stringify({ message: 'Deployment not found' })
});
break;
}
// Send update if status changed
const currentStatus = JSON.stringify(currentDeployment);
if (currentStatus !== lastStatus) {
await stream.writeSSE({
event: 'progress',
data: JSON.stringify({
status: currentDeployment.status,
progress: currentDeployment.progress,
currentStep: currentDeployment.currentStep,
url: currentDeployment.url,
error: currentDeployment.error
})
});
lastStatus = currentStatus;
}
// Exit if terminal state
if (currentDeployment.status === 'completed') {
await stream.writeSSE({
event: 'complete',
data: JSON.stringify({
url: currentDeployment.url,
status: 'ready'
})
});
break;
}
if (currentDeployment.status === 'failed') {
await stream.writeSSE({
event: 'error',
data: JSON.stringify({
message: currentDeployment.error || 'Deployment failed',
status: 'failed'
})
});
break;
}
// Wait before next check
await stream.sleep(1000);
}
} catch (error) {
console.error('SSE stream error:', error);
}
});
});
// Check name availability
app.get('/api/check/:name', async (c) => {
try {
const name = c.req.param('name');
// Validate name format
const validation = validateStackName(name);
if (!validation.valid) {
return c.json({
available: false,
valid: false,
error: validation.error
});
}
const normalizedName = name.trim().toLowerCase();
// Check if project exists
const dokployClient = createDokployClient();
const projectName = `ai-stack-${normalizedName}`;
const existingProject = await dokployClient.findProjectByName(projectName);
return c.json({
available: !existingProject,
valid: true,
name: normalizedName
});
} catch (error) {
console.error('Check endpoint error:', error);
return c.json({
available: false,
valid: false,
error: 'Failed to check availability'
}, 500);
}
});
// Serve static files (frontend)
app.use('/static/*', serveStatic({ root: './src/frontend' }));
app.use('/*', serveStatic({ root: './src/frontend', path: '/index.html' }));
console.log(`🚀 AI Stack Deployer starting on http://${HOST}:${PORT}`);
export default {
port: PORT,
hostname: HOST,
fetch: app.fetch,
};

View File

@@ -1,374 +0,0 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun';
import { streamSSE } from 'hono/streaming';
import { createProductionDokployClient } from './api/dokploy-production.js';
import { ProductionDeployer } from './orchestrator/production-deployer.js';
import type { DeploymentState as OrchestratorDeploymentState } from './orchestrator/production-deployer.js';
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
// Extended deployment state for HTTP server (adds logs)
interface HttpDeploymentState extends OrchestratorDeploymentState {
logs: string[];
}
const deployments = new Map<string, HttpDeploymentState>();
// Generate a unique deployment ID
function generateDeploymentId(): string {
return `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Validate stack name
function validateStackName(name: string): { valid: boolean; error?: string } {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'Name is required' };
}
const trimmedName = name.trim().toLowerCase();
if (trimmedName.length < 3 || trimmedName.length > 20) {
return { valid: false, error: 'Name must be between 3 and 20 characters' };
}
if (!/^[a-z0-9-]+$/.test(trimmedName)) {
return { valid: false, error: 'Name can only contain lowercase letters, numbers, and hyphens' };
}
if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) {
return { valid: false, error: 'Name cannot start or end with a hyphen' };
}
const reservedNames = (process.env.RESERVED_NAMES || 'admin,api,www,root,system,test,demo,portal').split(',');
if (reservedNames.includes(trimmedName)) {
return { valid: false, error: `Name "${trimmedName}" is reserved` };
}
return { valid: true };
}
// Main deployment orchestration using production components
async function deployStack(deploymentId: string): Promise<void> {
const deployment = deployments.get(deploymentId);
if (!deployment) {
throw new Error('Deployment not found');
}
try {
const client = createProductionDokployClient();
const deployer = new ProductionDeployer(client);
// Execute deployment with production orchestrator
const result = await deployer.deploy({
stackName: deployment.stackName,
dockerImage: process.env.STACK_IMAGE || 'git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest',
domainSuffix: process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl',
port: 8080,
healthCheckTimeout: 60000, // 60 seconds
healthCheckInterval: 5000, // 5 seconds
});
// Update deployment state with orchestrator result
deployment.phase = result.state.phase;
deployment.status = result.state.status;
deployment.progress = result.state.progress;
deployment.message = result.state.message;
deployment.url = result.state.url;
deployment.error = result.state.error;
deployment.resources = result.state.resources;
deployment.timestamps = result.state.timestamps;
deployment.logs = result.logs;
deployments.set(deploymentId, { ...deployment });
} catch (error) {
// Deployment failed catastrophically (before orchestrator could handle it)
deployment.status = 'failure';
deployment.phase = 'failed';
deployment.error = {
phase: deployment.phase,
message: error instanceof Error ? error.message : 'Unknown error',
code: 'DEPLOYMENT_FAILED',
};
deployment.message = 'Deployment failed';
deployment.timestamps.completed = new Date().toISOString();
deployments.set(deploymentId, { ...deployment });
throw error;
}
}
const app = new Hono();
app.use('*', logger());
app.use('*', cors());
app.get('/health', (c) => {
return c.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '0.2.0', // Bumped version for production components
service: 'ai-stack-deployer',
activeDeployments: deployments.size,
features: {
productionClient: true,
retryLogic: true,
circuitBreaker: true,
autoRollback: true,
healthVerification: true,
}
});
});
// Deploy endpoint
app.post('/api/deploy', async (c) => {
try {
const body = await c.req.json();
const { name } = body;
// Validate name
const validation = validateStackName(name);
if (!validation.valid) {
return c.json({
success: false,
error: validation.error,
code: 'INVALID_NAME'
}, 400);
}
const normalizedName = name.trim().toLowerCase();
// Check if name is already taken
const client = createProductionDokployClient();
const projectName = `ai-stack-${normalizedName}`;
const existingProject = await client.findProjectByName(projectName);
if (existingProject) {
return c.json({
success: false,
error: 'Name already taken',
code: 'NAME_EXISTS'
}, 409);
}
// Create deployment state
const deploymentId = generateDeploymentId();
const deployment: HttpDeploymentState = {
id: deploymentId,
stackName: normalizedName,
phase: 'initializing',
status: 'in_progress',
progress: 0,
message: 'Initializing deployment',
resources: {},
timestamps: {
started: new Date().toISOString(),
},
logs: [],
};
deployments.set(deploymentId, deployment);
// Start deployment in background
deployStack(deploymentId).catch(err => {
console.error(`Deployment ${deploymentId} failed:`, err);
});
return c.json({
success: true,
deploymentId,
url: `https://${normalizedName}.${process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl'}`,
statusEndpoint: `/api/status/${deploymentId}`
});
} catch (error) {
console.error('Deploy endpoint error:', error);
return c.json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
code: 'INTERNAL_ERROR'
}, 500);
}
});
// Status endpoint with SSE
app.get('/api/status/:deploymentId', (c) => {
const deploymentId = c.req.param('deploymentId');
const deployment = deployments.get(deploymentId);
if (!deployment) {
return c.json({
success: false,
error: 'Deployment not found',
code: 'NOT_FOUND'
}, 404);
}
return streamSSE(c, async (stream) => {
let lastStatus = '';
try {
// Stream updates until deployment completes or fails
while (true) {
const currentDeployment = deployments.get(deploymentId);
if (!currentDeployment) {
await stream.writeSSE({
event: 'error',
data: JSON.stringify({ message: 'Deployment not found' })
});
break;
}
// Send update if status changed
const currentStatus = JSON.stringify({
phase: currentDeployment.phase,
status: currentDeployment.status,
progress: currentDeployment.progress,
message: currentDeployment.message,
});
if (currentStatus !== lastStatus) {
await stream.writeSSE({
event: 'progress',
data: JSON.stringify({
phase: currentDeployment.phase,
status: currentDeployment.status,
progress: currentDeployment.progress,
message: currentDeployment.message,
currentStep: currentDeployment.message, // Backward compatibility
url: currentDeployment.url,
error: currentDeployment.error?.message,
resources: currentDeployment.resources,
})
});
lastStatus = currentStatus;
}
// Exit if terminal state
if (currentDeployment.status === 'success' || currentDeployment.phase === 'completed') {
await stream.writeSSE({
event: 'complete',
data: JSON.stringify({
url: currentDeployment.url,
status: 'ready',
resources: currentDeployment.resources,
duration: currentDeployment.timestamps.completed && currentDeployment.timestamps.started
? (new Date(currentDeployment.timestamps.completed).getTime() -
new Date(currentDeployment.timestamps.started).getTime()) / 1000
: null,
})
});
break;
}
if (currentDeployment.status === 'failure' || currentDeployment.phase === 'failed') {
await stream.writeSSE({
event: 'error',
data: JSON.stringify({
message: currentDeployment.error?.message || 'Deployment failed',
status: 'failed',
phase: currentDeployment.error?.phase,
code: currentDeployment.error?.code,
})
});
break;
}
// Wait before next check
await stream.sleep(1000);
}
} catch (error) {
console.error('SSE stream error:', error);
}
});
});
// Get deployment details (new endpoint for debugging)
app.get('/api/deployment/:deploymentId', (c) => {
const deploymentId = c.req.param('deploymentId');
const deployment = deployments.get(deploymentId);
if (!deployment) {
return c.json({
success: false,
error: 'Deployment not found',
code: 'NOT_FOUND'
}, 404);
}
return c.json({
success: true,
deployment: {
id: deployment.id,
stackName: deployment.stackName,
phase: deployment.phase,
status: deployment.status,
progress: deployment.progress,
message: deployment.message,
url: deployment.url,
error: deployment.error,
resources: deployment.resources,
timestamps: deployment.timestamps,
logs: deployment.logs.slice(-50), // Last 50 log entries
}
});
});
// Check name availability
app.get('/api/check/:name', async (c) => {
try {
const name = c.req.param('name');
// Validate name format
const validation = validateStackName(name);
if (!validation.valid) {
return c.json({
available: false,
valid: false,
error: validation.error
});
}
const normalizedName = name.trim().toLowerCase();
// Check if project exists
const client = createProductionDokployClient();
const projectName = `ai-stack-${normalizedName}`;
const existingProject = await client.findProjectByName(projectName);
return c.json({
available: !existingProject,
valid: true,
name: normalizedName
});
} catch (error) {
console.error('Check endpoint error:', error);
return c.json({
available: false,
valid: false,
error: 'Failed to check availability'
}, 500);
}
});
// Serve static files (frontend)
app.use('/static/*', serveStatic({ root: './src/frontend' }));
app.use('/*', serveStatic({ root: './src/frontend', path: '/index.html' }));
console.log(`🚀 AI Stack Deployer (Production) starting on http://${HOST}:${PORT}`);
console.log(`✅ Production features enabled:`);
console.log(` - Retry logic with exponential backoff`);
console.log(` - Circuit breaker pattern`);
console.log(` - Automatic rollback on failure`);
console.log(` - Health verification`);
console.log(` - Structured logging`);
export default {
port: PORT,
hostname: HOST,
fetch: app.fetch,
};