Project Browsers 1.0
This commit is contained in:
52
lib/centrifugo.js
Normal file
52
lib/centrifugo.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const crypto = require('crypto');
|
||||
const http = require('http');
|
||||
|
||||
const HMAC_SECRET = 'f6aeeb774a009d7b2a2bea348ea23c5f82de55ca38f3f3a2263ecc3cca1314b1';
|
||||
const API_KEY = 'f176d279faef1256d209d921e85cd5e27c19ec3b6f057c6829c9ccb18be9af85';
|
||||
const CENTRIFUGO_PORT = 8787;
|
||||
const CHANNEL = 'projects-browser';
|
||||
|
||||
function createJWT(sub, expSeconds = 86400) {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const payload = { sub, exp: Math.floor(Date.now() / 1000) + expSeconds };
|
||||
const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
|
||||
const h = b64(header);
|
||||
const p = b64(payload);
|
||||
const sig = crypto.createHmac('sha256', HMAC_SECRET).update(`${h}.${p}`).digest('base64url');
|
||||
return `${h}.${p}.${sig}`;
|
||||
}
|
||||
|
||||
function publish(data) {
|
||||
const body = JSON.stringify({ method: 'publish', params: { channel: CHANNEL, data } });
|
||||
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: CENTRIFUGO_PORT,
|
||||
path: '/api',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `apikey ${API_KEY}`,
|
||||
},
|
||||
}, (res) => {
|
||||
// drain response
|
||||
res.resume();
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error('Centrifugo publish error:', err.message);
|
||||
});
|
||||
|
||||
req.write(body);
|
||||
req.end();
|
||||
}
|
||||
|
||||
function publishStatus(slug, status, extra = {}) {
|
||||
publish({ type: 'status', slug, status, ...extra });
|
||||
}
|
||||
|
||||
function publishScreenshot(slug) {
|
||||
publish({ type: 'screenshot', slug, url: `/screenshots/${slug}.png?t=${Date.now()}` });
|
||||
}
|
||||
|
||||
module.exports = { createJWT, publish, publishStatus, publishScreenshot, CHANNEL };
|
||||
30
lib/env-parser.js
Normal file
30
lib/env-parser.js
Normal file
@@ -0,0 +1,30 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function parseEnvFile(filePath) {
|
||||
const result = {};
|
||||
if (!fs.existsSync(filePath)) return result;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, eqIndex).trim();
|
||||
let value = trimmed.slice(eqIndex + 1).trim();
|
||||
|
||||
// Strip surrounding quotes
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { parseEnvFile };
|
||||
241
lib/process-manager.js
Normal file
241
lib/process-manager.js
Normal file
@@ -0,0 +1,241 @@
|
||||
const { spawn, execSync } = require('child_process');
|
||||
const net = require('net');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
const processes = new Map();
|
||||
const events = new EventEmitter();
|
||||
|
||||
function key(slug, type) { return `${slug}:${type}`; }
|
||||
|
||||
function createEntry() {
|
||||
return { child: null, status: 'stopped', logs: [], port: null };
|
||||
}
|
||||
|
||||
function getStatus(slug) {
|
||||
const api = processes.get(key(slug, 'api'));
|
||||
const frontend = processes.get(key(slug, 'frontend'));
|
||||
return {
|
||||
api: api
|
||||
? { status: api.status, pid: api.child?.pid || null, logs: api.logs }
|
||||
: { status: 'stopped', pid: null, logs: [] },
|
||||
frontend: frontend
|
||||
? { status: frontend.status, pid: frontend.child?.pid || null, logs: frontend.logs }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function combinedStatus(slug, hasFrontend) {
|
||||
const s = getStatus(slug);
|
||||
if (!hasFrontend) return s.api.status;
|
||||
// Both must be running for "running"; if either is error, "error"; else worst state
|
||||
const apiSt = s.api.status;
|
||||
const feSt = s.frontend?.status || 'stopped';
|
||||
if (apiSt === 'running' && feSt === 'running') return 'running';
|
||||
if (apiSt === 'error' || feSt === 'error') return 'error';
|
||||
if (apiSt === 'starting' || feSt === 'starting') return 'starting';
|
||||
if (apiSt === 'stopping' || feSt === 'stopping') return 'stopping';
|
||||
return 'stopped';
|
||||
}
|
||||
|
||||
function setStatus(entry, slug, subtype, status) {
|
||||
if (entry.status === status) return;
|
||||
entry.status = status;
|
||||
events.emit('status', slug, subtype, status);
|
||||
}
|
||||
|
||||
function pushLog(entry, line) {
|
||||
entry.logs.push(line);
|
||||
if (entry.logs.length > 50) entry.logs.shift();
|
||||
}
|
||||
|
||||
function spawnProcess(slug, subtype, cmd, args, cwd) {
|
||||
const k = key(slug, subtype);
|
||||
const existing = processes.get(k);
|
||||
if (existing && (existing.status === 'running' || existing.status === 'starting')) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const entry = createEntry();
|
||||
processes.set(k, entry);
|
||||
|
||||
const label = `[${slug}:${subtype}]`;
|
||||
console.log(`${label} Spawning: ${cmd} ${args.join(' ')} (cwd: ${cwd})`);
|
||||
|
||||
const child = spawn(cmd, args, {
|
||||
cwd,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
|
||||
entry.child = child;
|
||||
entry.status = 'starting';
|
||||
events.emit('status', slug, subtype, 'starting');
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) {
|
||||
pushLog(entry, `[out] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
for (const line of data.toString().split('\n').filter(Boolean)) {
|
||||
pushLog(entry, `[err] ${line}`);
|
||||
console.log(`${label} [err] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(`${label} Spawn error: ${err.message}`);
|
||||
setStatus(entry, slug, subtype, 'error');
|
||||
pushLog(entry, `[sys] Error: ${err.message}`);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
console.log(`${label} Exited code=${code} signal=${signal}`);
|
||||
setStatus(entry, slug, subtype, 'stopped');
|
||||
pushLog(entry, `[sys] Exited code=${code} signal=${signal}`);
|
||||
entry.child = null;
|
||||
});
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
function start(slug, projectPath, port, frontend) {
|
||||
// Start API
|
||||
const apiEntry = spawnProcess(slug, 'api', 'php',
|
||||
['artisan', 'serve', `--port=${port}`, '--host=0.0.0.0'],
|
||||
projectPath
|
||||
);
|
||||
apiEntry.port = port;
|
||||
healthCheck(slug, 'api', port, apiEntry);
|
||||
|
||||
let feResult = null;
|
||||
// Start frontend if present
|
||||
if (frontend) {
|
||||
const isExisting = processes.get(key(slug, 'frontend'));
|
||||
if (isExisting && (isExisting.status === 'running' || isExisting.status === 'starting')) {
|
||||
feResult = { status: isExisting.status, message: 'Already running' };
|
||||
} else {
|
||||
let cmd, args;
|
||||
if (frontend.packageManager === 'pnpm') {
|
||||
cmd = 'pnpm';
|
||||
args = ['dev', '--port', String(frontend.port), '--host', '0.0.0.0'];
|
||||
} else {
|
||||
cmd = 'npm';
|
||||
args = ['run', 'dev', '--', '--port', String(frontend.port), '--host', '0.0.0.0'];
|
||||
}
|
||||
|
||||
const feEntry = spawnProcess(slug, 'frontend', cmd, args, frontend.fullPath);
|
||||
feEntry.port = frontend.port;
|
||||
healthCheck(slug, 'frontend', frontend.port, feEntry);
|
||||
feResult = { status: 'starting', pid: feEntry.child?.pid };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
api: { status: apiEntry.status, pid: apiEntry.child?.pid },
|
||||
frontend: feResult,
|
||||
};
|
||||
}
|
||||
|
||||
function healthCheck(slug, subtype, port, entry, attempt = 0) {
|
||||
const maxAttempts = subtype === 'frontend' ? 60 : 15;
|
||||
const interval = subtype === 'frontend' ? 3000 : 1000;
|
||||
|
||||
if (attempt > maxAttempts) {
|
||||
if (entry.status === 'starting') setStatus(entry, slug, subtype, 'error');
|
||||
pushLog(entry, '[sys] Health check timed out');
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (entry.status !== 'starting') return;
|
||||
|
||||
// Use TCP connect check instead of HTTP — avoids socket pool exhaustion
|
||||
const sock = net.connect(port, '127.0.0.1');
|
||||
sock.setTimeout(2000);
|
||||
sock.on('connect', () => {
|
||||
sock.destroy();
|
||||
setStatus(entry, slug, subtype, 'running');
|
||||
pushLog(entry, `[sys] Port ${port} responding`);
|
||||
});
|
||||
sock.on('error', () => {
|
||||
sock.destroy();
|
||||
healthCheck(slug, subtype, port, entry, attempt + 1);
|
||||
});
|
||||
sock.on('timeout', () => {
|
||||
sock.destroy();
|
||||
healthCheck(slug, subtype, port, entry, attempt + 1);
|
||||
});
|
||||
}, interval);
|
||||
}
|
||||
|
||||
function stopEntry(k, slug, subtype) {
|
||||
const entry = processes.get(k);
|
||||
if (!entry || !entry.child) {
|
||||
processes.delete(k);
|
||||
return Promise.resolve({ status: 'stopped' });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = entry.child;
|
||||
setStatus(entry, slug, subtype, 'stopping');
|
||||
|
||||
child.on('exit', () => {
|
||||
setStatus(entry, slug, subtype, 'stopped');
|
||||
entry.child = null;
|
||||
resolve({ status: 'stopped' });
|
||||
});
|
||||
|
||||
try { process.kill(-child.pid, 'SIGTERM'); } catch {}
|
||||
|
||||
setTimeout(() => {
|
||||
if (entry.child) {
|
||||
try { process.kill(-child.pid, 'SIGKILL'); } catch {}
|
||||
}
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
function stop(slug) {
|
||||
return Promise.all([
|
||||
stopEntry(key(slug, 'api'), slug, 'api'),
|
||||
stopEntry(key(slug, 'frontend'), slug, 'frontend'),
|
||||
]);
|
||||
}
|
||||
|
||||
function stopAll() {
|
||||
const slugsSeen = new Set();
|
||||
for (const [k] of processes) {
|
||||
const slug = k.split(':')[0];
|
||||
slugsSeen.add(slug);
|
||||
}
|
||||
return Promise.all([...slugsSeen].map(s => stop(s)));
|
||||
}
|
||||
|
||||
function getAllStatuses() {
|
||||
const result = {};
|
||||
for (const [k] of processes) {
|
||||
const slug = k.split(':')[0];
|
||||
if (!result[slug]) result[slug] = getStatus(slug);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function killAllSync() {
|
||||
for (const [, entry] of processes) {
|
||||
if (entry.child) {
|
||||
try { process.kill(-entry.child.pid, 'SIGTERM'); } catch {}
|
||||
}
|
||||
}
|
||||
// Brief wait then SIGKILL any survivors
|
||||
try { execSync('sleep 1'); } catch {}
|
||||
for (const [, entry] of processes) {
|
||||
if (entry.child) {
|
||||
try { process.kill(-entry.child.pid, 'SIGKILL'); } catch {}
|
||||
}
|
||||
}
|
||||
processes.clear();
|
||||
}
|
||||
|
||||
module.exports = { start, stop, stopAll, getStatus, combinedStatus, getAllStatuses, events, killAllSync };
|
||||
132
lib/scanner.js
Normal file
132
lib/scanner.js
Normal file
@@ -0,0 +1,132 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parseEnvFile } = require('./env-parser');
|
||||
|
||||
const BASE_DIR = path.resolve(__dirname, '../../');
|
||||
const SKIP_DIRS = new Set([
|
||||
'backups', 'varia', 'wn-golem-starter', 'summercms.io', 'docs', 'projects-browser'
|
||||
]);
|
||||
|
||||
function extractPortFromUrl(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const port = parseInt(u.port, 10);
|
||||
return port || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function detectFrontend(dirPath) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('vue-')) continue;
|
||||
const frontendDir = path.join(dirPath, entry.name);
|
||||
const pkgPath = path.join(frontendDir, 'package.json');
|
||||
if (!fs.existsSync(pkgPath)) continue;
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
if (!pkg.scripts?.dev) continue;
|
||||
} catch { continue; }
|
||||
|
||||
const hasPnpmLock = fs.existsSync(path.join(frontendDir, 'pnpm-lock.yaml'));
|
||||
return {
|
||||
dir: entry.name,
|
||||
fullPath: frontendDir,
|
||||
packageManager: hasPnpmLock ? 'pnpm' : 'npm',
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scanProjects() {
|
||||
const projects = [];
|
||||
const entries = fs.readdirSync(BASE_DIR, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
||||
|
||||
const dirPath = path.join(BASE_DIR, entry.name);
|
||||
|
||||
// Check if artisan exists directly
|
||||
if (fs.existsSync(path.join(dirPath, 'artisan'))) {
|
||||
projects.push(buildProjectMeta(dirPath, entry.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check one level deeper
|
||||
try {
|
||||
const subEntries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
for (const sub of subEntries) {
|
||||
if (!sub.isDirectory() || sub.name.startsWith('.')) continue;
|
||||
const subPath = path.join(dirPath, sub.name);
|
||||
if (fs.existsSync(path.join(subPath, 'artisan'))) {
|
||||
projects.push(buildProjectMeta(subPath, entry.name));
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Sort by slug for deterministic port assignment
|
||||
projects.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
|
||||
// Assign API ports to projects without one
|
||||
let nextPort = 8100;
|
||||
const usedPorts = new Set(projects.filter(p => p.port).map(p => p.port));
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.port) {
|
||||
while (usedPorts.has(nextPort)) nextPort++;
|
||||
project.port = nextPort;
|
||||
project.portSource = 'assigned';
|
||||
usedPorts.add(nextPort);
|
||||
nextPort++;
|
||||
}
|
||||
}
|
||||
|
||||
// Assign frontend ports deterministically from 3010+
|
||||
let nextFrontendPort = 3010;
|
||||
for (const project of projects) {
|
||||
if (project.frontend) {
|
||||
project.frontend.port = nextFrontendPort++;
|
||||
}
|
||||
}
|
||||
|
||||
return projects;
|
||||
}
|
||||
|
||||
function buildProjectMeta(dirPath, slug) {
|
||||
const envPath = path.join(dirPath, '.env');
|
||||
const hasEnv = fs.existsSync(envPath);
|
||||
const env = hasEnv ? parseEnvFile(envPath) : {};
|
||||
|
||||
const port = extractPortFromUrl(env.APP_URL);
|
||||
|
||||
// Make SQLite path relative if it starts with the project path
|
||||
let dbName = env.DB_DATABASE || null;
|
||||
if (dbName && dbName.startsWith(dirPath)) {
|
||||
dbName = path.relative(dirPath, dbName);
|
||||
}
|
||||
|
||||
const frontend = detectFrontend(dirPath);
|
||||
const hasDocs = fs.existsSync(path.join(dirPath, 'docs'));
|
||||
|
||||
return {
|
||||
slug,
|
||||
name: slug,
|
||||
path: dirPath,
|
||||
port: port,
|
||||
portSource: port ? '.env' : null,
|
||||
hasEnv,
|
||||
dbType: env.DB_CONNECTION || null,
|
||||
dbName,
|
||||
frontend,
|
||||
hasDocs,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { scanProjects };
|
||||
33
lib/screenshotter.js
Normal file
33
lib/screenshotter.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const { execFile } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const SCREENSHOTS_DIR = path.join(__dirname, '../public/screenshots');
|
||||
|
||||
function takeScreenshot(slug, port, useLocalhost = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outPath = path.join(SCREENSHOTS_DIR, `${slug}.png`);
|
||||
const host = useLocalhost ? 'localhost' : '127.0.0.1';
|
||||
const url = `http://${host}:${port}`;
|
||||
|
||||
const args = [
|
||||
'screenshot',
|
||||
'--viewport-size', '1280,800',
|
||||
'--wait-for-timeout', '3000',
|
||||
url,
|
||||
outPath,
|
||||
];
|
||||
|
||||
execFile('/usr/bin/playwright', args, { timeout: 30000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(new Error(`Screenshot failed: ${err.message}\n${stderr}`));
|
||||
} else if (fs.existsSync(outPath)) {
|
||||
resolve(`/screenshots/${slug}.png`);
|
||||
} else {
|
||||
reject(new Error('Screenshot file not created'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { takeScreenshot };
|
||||
Reference in New Issue
Block a user